Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb47f5c7f4 | ||
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e | ||
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf | ||
|
|
72fcef2ba9 | ||
|
|
5a537f5dc3 | ||
|
|
8ab6569974 | ||
|
|
69a0aceba6 | ||
|
|
d76d7943fc | ||
|
|
d125eb399e | ||
|
|
e6c1f7dbb3 | ||
|
|
465853ac59 |
+2
-2
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
|
||||
DEFAULT_LANG=fr
|
||||
|
||||
# ── Database ──
|
||||
# SQLite (default): sqlite:////data/flowdeck.db
|
||||
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
|
||||
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
|
||||
# schéma retombe silencieusement sur /data/flowdeck.db).
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
|
||||
# ── Sync ──
|
||||
|
||||
+173
@@ -1,5 +1,178 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
|
||||
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
|
||||
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
|
||||
la valeur par défaut (il signe les sessions)
|
||||
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
|
||||
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
|
||||
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
|
||||
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
|
||||
(croissance mémoire bornée)
|
||||
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
|
||||
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
|
||||
scheduler passés en `warning`
|
||||
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
|
||||
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
|
||||
dupliqué retiré du ROADMAP
|
||||
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
|
||||
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
|
||||
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
|
||||
(format ISO naïf identique, zéro changement de comportement)
|
||||
|
||||
### Notes
|
||||
|
||||
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
|
||||
l'alignement à 3.13 implique un rebuild d'image à valider
|
||||
|
||||
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
|
||||
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
|
||||
d'événements/webhooks et des écritures sont enfin visibles dans les logs
|
||||
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
|
||||
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
|
||||
transaction au lieu de commiter une collection sans schéma
|
||||
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
|
||||
point d'entrée des connexions) ; le wrapper async + les 510 call sites
|
||||
synchrones sur l'event loop restent à migrer
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
|
||||
|
||||
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
|
||||
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
|
||||
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
|
||||
`ping()` du serveur vers un `api_base` interne
|
||||
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
|
||||
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
|
||||
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
|
||||
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
|
||||
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
|
||||
|
||||
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
|
||||
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
|
||||
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
|
||||
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
|
||||
préfixes sortent d'`EXCLUDED_PATHS`
|
||||
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
|
||||
(interpolations Jinja neutralisées) — 0 échec avant/après
|
||||
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
|
||||
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
|
||||
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
|
||||
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
|
||||
|
||||
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
|
||||
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
|
||||
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
|
||||
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
|
||||
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
|
||||
`welcome.html` équipés du header
|
||||
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
|
||||
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
|
||||
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
|
||||
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
|
||||
route, pas le 403 du middleware) → suite **1026/1026**
|
||||
|
||||
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A16** — `_load_page_or_404` (4 exports) et les 2 routes pièce jointe
|
||||
(`/download`, `/file-content`) passent par session + `PermissionManager.can_view_page`
|
||||
→ 401 sans session, 404 hors ACL ; la lecture legacy `board.py` était déjà
|
||||
couverte par A7
|
||||
- Test `test_exports_and_attachments_require_auth` → suite **1026/1026**
|
||||
|
||||
## v7.3.3 (2026-09-30) — Audit sécurité : A12–A24 (SSRF, auth legacy, uploads, perf)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A12** — unfurl OG : `follow_redirects` manuel + `_is_public_host` à chaque
|
||||
saut → 400 vers loopback/link-local (ex. `169.254.169.254`)
|
||||
- **A13** — automations : `Depends(_require_session)` sur le router entier
|
||||
(CRUD, run, press-button) + action `webhook` validée avant POST
|
||||
- **A15** — webhooks sortants : admin exigé + URL publique (SSRF scheduler)
|
||||
- **A17** — router legacy `/api` : session ou Bearer (`/api/v1`) ; allowlist
|
||||
explicite `/api/health`, `/api/frontend-error`
|
||||
- **A22** — uploads locaux : session exigée, `validate_upload` branché (10 MB +
|
||||
extensions), `FLOWDECK_DATA_DIR` remplace le `/data` codé en dur
|
||||
- **A23** — N+1 : `GROUP BY` (compteurs de pages), `executemany` (cards de sync
|
||||
+ duplicata de propriétés avec remap d'ids vérifié)
|
||||
- **A24** — 2 routes silencieusement écrasées supprimées + test « aucun doublon
|
||||
méthode+chemin » sur les 680 routes
|
||||
|
||||
### Tests
|
||||
|
||||
- +9 non-régressions dans `tests/test_audit_p0_fixes.py` → suite **1025/1025**
|
||||
|
||||
## v7.3.2 (2026-09-30) — Audit sécurité : A11 + A18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A11** — `GET /api/settings/avatar/{filename:path}` : `resolve()` +
|
||||
`relative_to()` (le motif de `serve_uploaded_file`) → 403 hors `/data/avatars`
|
||||
- **A18** — `GET /workspace/public/{id}` : 404 explicite pour les bases
|
||||
`restricted`/`private` (`permission_type`) et `html.escape` sur nom, icône et
|
||||
titres de lignes — ce f-string HTML ne passe pas par Jinja2, donc l'autoescape
|
||||
A10 ne le couvrait pas
|
||||
- Tests : `tests/test_audit_p0_fixes.py` (3 non-régressions) — suite **1019/1019**
|
||||
|
||||
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
|
||||
|
||||
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
|
||||
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
|
||||
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
|
||||
cycle v6.8→v7.3 committé + tag `v7.3.0`
|
||||
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
|
||||
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
|
||||
sorti de la liste CSRF exemptée
|
||||
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
|
||||
chemin legacy `user_id=0` supprimé
|
||||
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
|
||||
global), placeholder user créé en `is_admin=0`
|
||||
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
|
||||
legacy single-user sur les collections)
|
||||
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
|
||||
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
|
||||
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
|
||||
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
|
||||
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
|
||||
rotation de `APP_SECRET_KEY`
|
||||
- **A10** — `app/templating.py` : un seul `ENV` avec
|
||||
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
|
||||
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
|
||||
en `|tojson`)
|
||||
|
||||
### Tests
|
||||
|
||||
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
|
||||
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
|
||||
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
|
||||
|
||||
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
|
||||
|
||||
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
+31
-34
@@ -1049,10 +1049,6 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
| Feature | Fichiers | Note |
|
||||
@@ -1119,48 +1115,48 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
|
||||
### 🔴 P0 — Critique (avant toute exposition réseau)
|
||||
|
||||
- [ ] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
|
||||
- [ ] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
|
||||
- [ ] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
|
||||
- [ ] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
|
||||
- [ ] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
|
||||
- [ ] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
|
||||
- [ ] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
|
||||
- [ ] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
|
||||
- [ ] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
|
||||
- [ ] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
|
||||
- [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
|
||||
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
|
||||
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
|
||||
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
|
||||
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
|
||||
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
|
||||
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
|
||||
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
|
||||
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
|
||||
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
|
||||
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
|
||||
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
|
||||
|
||||
### 🟠 P1 — Hautes
|
||||
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
||||
- [ ] **A19 — Liste CSRF trop large (34 préfixes, match `startswith`)** : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
|
||||
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
|
||||
- [x] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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**.*
|
||||
- [x] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
|
||||
|
||||
### 🟡 P2 — Moyennes
|
||||
|
||||
- [ ] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
|
||||
- [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
|
||||
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
|
||||
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
|
||||
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
|
||||
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
|
||||
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
|
||||
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
|
||||
- [ ] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
|
||||
- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
|
||||
- [ ] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**.*
|
||||
- [ ] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
|
||||
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
|
||||
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
|
||||
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
|
||||
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
|
||||
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
|
||||
|
||||
### 🟢 P3 — Basses / hygiène
|
||||
@@ -1170,7 +1166,7 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
|
||||
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
|
||||
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
|
||||
- [ ] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
|
||||
- [x] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
|
||||
|
||||
### ✅ Vérifié non-problème (ne pas re-checker)
|
||||
|
||||
@@ -1185,3 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||
|
||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9.**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.0 (cycle v7 — versions v6.8→v7.3 livrées) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
@@ -31,7 +31,7 @@ class SessionManager:
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -94,7 +94,7 @@ class SessionManager:
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_touch_session")
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
|
||||
+3
-4
@@ -22,9 +22,6 @@ class Settings(BaseSettings):
|
||||
github_oauth_client_id: str = ""
|
||||
github_oauth_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||
oauth_redirect_uri: str = ""
|
||||
@@ -129,7 +126,9 @@ class Settings(BaseSettings):
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z]:', p):
|
||||
return Path(p)
|
||||
return Path("/" + p)
|
||||
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
|
||||
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
|
||||
return Path("/" + p.lstrip("/"))
|
||||
return Path("/data/flowdeck.db")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+60
-16
@@ -71,57 +71,101 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _spawn(name: str, factory):
|
||||
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
|
||||
|
||||
Loggue l'exception puis recrée la coroutine 10 s plus tard.
|
||||
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
|
||||
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
|
||||
le bruit devient un problème.
|
||||
"""
|
||||
|
||||
async def _guard():
|
||||
while True:
|
||||
try:
|
||||
await factory()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
else:
|
||||
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return asyncio.create_task(_guard())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
admin_hash = hash_password("FlowDeck2026!")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(admin_hash,)
|
||||
|
||||
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
|
||||
if settings.app_secret_key == "change-me-to-random":
|
||||
raise RuntimeError(
|
||||
"APP_SECRET_KEY non défini — générer une valeur : "
|
||||
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
||||
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone():
|
||||
admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password(admin_pw),),
|
||||
)
|
||||
conn.commit()
|
||||
logger.warning(
|
||||
"Premier démarrage : compte admin créé, mot de passe = %s "
|
||||
"(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)",
|
||||
admin_pw,
|
||||
)
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
|
||||
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
automation_task = _spawn("automation_scheduler", automation_scheduler)
|
||||
|
||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||
from app.services.backup import backup_scheduler
|
||||
backup_task = asyncio.create_task(backup_scheduler())
|
||||
backup_task = _spawn("backup_scheduler", backup_scheduler)
|
||||
|
||||
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
||||
from app.services.projects import project_sync_scheduler
|
||||
projects_task = asyncio.create_task(project_sync_scheduler())
|
||||
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
|
||||
|
||||
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
|
||||
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = asyncio.create_task(semantic_index_scheduler())
|
||||
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||
|
||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||
from app.services.calendar_sync import calendar_sync_scheduler
|
||||
calendar_task = asyncio.create_task(calendar_sync_scheduler())
|
||||
calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
try:
|
||||
@@ -141,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.3.0",
|
||||
version="7.3.9",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
+15
-1
@@ -18,7 +18,21 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
||||
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"}
|
||||
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
||||
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
||||
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
||||
# library, gitea_workspace, workspace, workspaces, welcome).
|
||||
# Ne restent que du machine-to-machine / hors session :
|
||||
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
|
||||
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
|
||||
# - publics : /s/ (sites), /f/ (forms)
|
||||
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
||||
EXCLUDED_PATHS = {
|
||||
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
|
||||
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
|
||||
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
|
||||
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Paths that should be rate-limited
|
||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||
"/api/", "/board/api/", "/auth/",
|
||||
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
||||
# API workspace + collections (les endpoints mutants du legacy).
|
||||
"/scim/v2/", "/workspace/", "/db/",
|
||||
)
|
||||
|
||||
# Pages publiques : seul le non-GET est plafonné (brute force de
|
||||
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
||||
# visiteurs d'un site publié qui partagent une IP.
|
||||
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
||||
|
||||
# Paths exempt from rate limiting even under an API prefix
|
||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||
"/api/health",
|
||||
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"/api/frontend-errors",
|
||||
})
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
||||
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||
self._last_prune = 0.0
|
||||
self._max_keys = 5000
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if not settings.rate_limit_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Only rate-limit API routes
|
||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
||||
method = request.method.upper()
|
||||
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
||||
method not in ("GET", "HEAD", "OPTIONS")
|
||||
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
||||
)
|
||||
if not limited:
|
||||
return await call_next(request)
|
||||
|
||||
# Exempt health check and error capture
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
limit = self.max_requests or settings.rate_limit_requests
|
||||
ip = self._client_key(request)
|
||||
now = time.time()
|
||||
|
||||
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
||||
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
||||
self._prune(now)
|
||||
|
||||
window_start, count = self._store[ip]
|
||||
if now - window_start > self.window_seconds:
|
||||
self._store[ip] = (now, 1)
|
||||
return await call_next(request)
|
||||
|
||||
if count >= self.max_requests:
|
||||
if count >= limit:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
self._store[ip] = (window_start, count + 1)
|
||||
return await call_next(request)
|
||||
|
||||
def _client_key(self, request: Request) -> str:
|
||||
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
||||
|
||||
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
||||
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
||||
internaute, donc le XFF du proxy fait foi.
|
||||
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
||||
exposée directement), prendre la dernière adresse non privée de la
|
||||
chaîne plutôt que la première.
|
||||
"""
|
||||
host = request.client.host if request.client else "unknown"
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
try:
|
||||
direct = ipaddress.ip_address(host)
|
||||
if direct.is_private or direct.is_loopback:
|
||||
return fwd.split(",")[0].strip() or host
|
||||
except ValueError:
|
||||
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
||||
return host
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
||||
for k in expired:
|
||||
del self._store[k]
|
||||
self._last_prune = now
|
||||
|
||||
+1
-1
@@ -862,7 +862,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_migration_v630_api_v2")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
+44
-24
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
triggers = conn.execute(
|
||||
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
|
||||
).fetchall()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
for trig in triggers:
|
||||
last = trig["last_fired_at"]
|
||||
if last:
|
||||
@@ -92,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
logger.exception("Agent scheduler tick failed")
|
||||
|
||||
|
||||
async def _current_user_id(request: Request) -> int | None:
|
||||
async def _current_user_id(request: Request) -> int:
|
||||
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||
user = await get_current_user(request)
|
||||
if user and user.get("id"):
|
||||
return user["id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
return row["id"] if row else None
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
async def _workspace_id(request: Request) -> int | None:
|
||||
@@ -113,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
|
||||
|
||||
|
||||
async def _current_admin(request: Request) -> dict:
|
||||
"""Require an admin session. Falls back to the single admin row, matching
|
||||
the agent router's unauthenticated convention (single-user deployments)."""
|
||||
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||
user = await get_current_user(request)
|
||||
if user:
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return dict(row)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
|
||||
|
||||
def _default_agent(conn, user_id: int) -> dict:
|
||||
@@ -997,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
return {"ok": False, "provider": provider, "error": str(exc)}
|
||||
|
||||
|
||||
def _check_api_base(value: str) -> str:
|
||||
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
|
||||
|
||||
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
|
||||
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
|
||||
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
|
||||
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
|
||||
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
|
||||
settings `llm_allow_private=false`.
|
||||
"""
|
||||
url = (value or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
|
||||
return url
|
||||
|
||||
|
||||
@router.patch("/providers")
|
||||
async def update_provider_config(request: Request):
|
||||
await _current_admin(request)
|
||||
@@ -1008,7 +1028,7 @@ async def update_provider_config(request: Request):
|
||||
provider=provider or None,
|
||||
model=(body.get("model") or "").strip() or None,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
clear_keys=(provider == "offline"),
|
||||
)
|
||||
llm = LLMClient()
|
||||
@@ -1037,7 +1057,7 @@ async def test_provider_config(request: Request):
|
||||
llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
)
|
||||
try:
|
||||
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||
|
||||
+28
-24
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -15,7 +15,27 @@ from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
|
||||
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
|
||||
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
|
||||
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
|
||||
|
||||
|
||||
async def _require_session_or_bearer(request: Request) -> None:
|
||||
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
|
||||
if request.url.path in _API_PUBLIC_PATHS:
|
||||
return
|
||||
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
return
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
from app.routers.public_api import verify_token
|
||||
verify_token(auth)
|
||||
return
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
|
||||
|
||||
# ── Simple rate limiter (in-memory, per-IP) ──
|
||||
_rate_limit_store: dict[str, tuple[float, int]] = {}
|
||||
@@ -26,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
now = datetime.utcnow().timestamp()
|
||||
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
|
||||
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
||||
if now - window_start > 60:
|
||||
_rate_limit_store[ip] = (now, 1)
|
||||
@@ -47,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",
|
||||
@@ -79,22 +99,6 @@ async def stats():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
|
||||
"""List Gitea projects (JSON)."""
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception:
|
||||
repos = []
|
||||
return {"projects": repos}
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_card(
|
||||
request: Request,
|
||||
@@ -440,8 +444,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
"comments": comments,
|
||||
"checklists": checklists,
|
||||
}
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("card_detail.html")
|
||||
return HTMLResponse(template.render(**ctx))
|
||||
|
||||
|
||||
+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")
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -17,7 +17,15 @@ from app.services.automations import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["automations"])
|
||||
|
||||
|
||||
def _require_session(request: Request) -> None:
|
||||
"""A13 : toute la route (CRUD, run, press-button) exige une session."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["automations"], dependencies=[Depends(_require_session)])
|
||||
|
||||
TRIGGER_TYPES = ("event", "cron", "button")
|
||||
|
||||
@@ -75,7 +83,7 @@ async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user.get("id") or 1
|
||||
by = user["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
|
||||
+41
-31
@@ -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,
|
||||
@@ -804,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -908,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -1084,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
@@ -1213,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
@@ -1240,8 +1240,8 @@ async def board_view(
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
@@ -1385,6 +1385,9 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
@@ -1417,11 +1420,11 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
async def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1729,7 +1732,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
@@ -1905,7 +1908,11 @@ async def og_metadata(request: Request):
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
data = await fetch_og_metadata(url)
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
@@ -2033,7 +2040,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
@@ -2044,8 +2051,8 @@ async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
@@ -2138,12 +2145,15 @@ async def sync_project(owner: str, repo: str):
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
col = _issue_column(issue, columns, board_id)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
(board_id, issue["number"], col),
|
||||
)
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
+28
-14
@@ -43,23 +43,22 @@ def _session_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
@@ -374,20 +373,35 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for p in rows:
|
||||
ncur = conn.execute(
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"]),
|
||||
tuples,
|
||||
)
|
||||
prop_map[p["id"]] = ncur.lastrowid
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
|
||||
+125
-64
@@ -2,8 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -52,7 +53,7 @@ def _get_user_or_redirect(request: Request):
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
@@ -88,7 +89,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
for repo in repos[:10]:
|
||||
@@ -179,7 +180,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
@@ -193,7 +194,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
@@ -206,7 +207,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
@@ -252,10 +253,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -283,10 +283,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -312,10 +311,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -437,8 +435,8 @@ async def view_page_root(request: Request, page_id: int):
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -451,8 +449,8 @@ async def accounts_page(request: Request):
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
@@ -656,8 +654,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -689,11 +687,20 @@ def _get_user_id(request: Request) -> int:
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
@@ -702,13 +709,18 @@ async def update_profile(request: Request):
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
from app.password_utils import hash_password
|
||||
from app.password_utils import hash_password, verify_password
|
||||
body = await request.json()
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -717,7 +729,7 @@ async def update_password(request: Request):
|
||||
@router.post("/api/user/token")
|
||||
async def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -730,7 +742,7 @@ async def generate_token(request: Request):
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
async def disconnect_forge(request: Request, provider: str):
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
@@ -755,14 +767,14 @@ async def dashboard(
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
pass
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
@@ -777,7 +789,7 @@ async def dashboard(
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
@@ -790,7 +802,7 @@ async def dashboard(
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
@@ -807,8 +819,8 @@ async def dashboard(
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
@@ -820,8 +832,8 @@ async def dashboard(
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -839,8 +851,8 @@ async def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -901,9 +913,18 @@ async def list_workspace_projects(request: Request):
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
@@ -922,7 +943,7 @@ async def list_workspace_projects(request: Request):
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
@@ -1016,8 +1037,8 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1138,9 +1159,21 @@ def _file_page_disk_path(page: dict):
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
async def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1161,13 +1194,15 @@ async def download_page_file(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
async def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1451,7 +1486,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), item_id),
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -1515,7 +1550,8 @@ async def upload_local_workspace_file(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1532,7 +1568,12 @@ async def upload_local_workspace_file(request: Request):
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1553,10 +1594,14 @@ async def upload_local_workspace_file(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
@@ -1588,7 +1633,8 @@ async def upload_local_workspace_folder(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1610,7 +1656,12 @@ async def upload_local_workspace_folder(request: Request):
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1669,9 +1720,13 @@ async def upload_local_workspace_folder(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
@@ -1721,8 +1776,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1824,8 +1879,8 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1873,7 +1928,14 @@ async def serve_avatar_file(filename: str):
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
filepath = Path("/data/avatars") / filename
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
@@ -2036,7 +2098,7 @@ async def add_item_tag(request: Request, item_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
@@ -2142,9 +2204,8 @@ async def sidebar_workspace_tree(request: Request):
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -2180,7 +2241,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
@@ -2201,7 +2262,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2221,7 +2282,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
|
||||
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
|
||||
if ext not in _IMAGE_EXTS:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
ws_id = _active_ws(request)
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
|
||||
+15
-5
@@ -23,7 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["export"], prefix="/api/export")
|
||||
|
||||
|
||||
def _load_page_or_404(page_id: int) -> dict:
|
||||
def _load_page_or_404(request: Request, page_id: int) -> dict:
|
||||
"""A16 : session obligatoire + `PermissionManager.can_view_page` — l'export ne
|
||||
doit pas délivrer le contenu d'une page énumérable par id."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
@@ -31,6 +39,8 @@ def _load_page_or_404(page_id: int) -> dict:
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -52,7 +62,7 @@ def _safe_filename(page: dict, ext: str) -> str:
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
md = page_to_markdown(page)
|
||||
filename = _safe_filename(page, "md")
|
||||
headers = _download_header(filename, "text/markdown")
|
||||
@@ -61,7 +71,7 @@ async def export_markdown(page_id: int, request: Request):
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
html = page_to_standalone_html(page)
|
||||
filename = _safe_filename(page, "html")
|
||||
headers = _download_header(filename, "text/html")
|
||||
@@ -70,7 +80,7 @@ async def export_html(page_id: int, request: Request):
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
except ImportError:
|
||||
@@ -85,7 +95,7 @@ async def export_pdf(page_id: int, request: Request):
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
site_bytes = build_static_site_bytes(page)
|
||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||
filename = f"{title}_site.zip"
|
||||
|
||||
@@ -44,9 +44,9 @@ async def import_page(request: Request):
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
</div>"""
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
|
||||
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
|
||||
).fetchone()
|
||||
content = row["content"] if row else ""
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
|
||||
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
|
||||
if ws_count > 0:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("welcome.html")
|
||||
return HTMLResponse(content=template.render(
|
||||
user=user,
|
||||
|
||||
@@ -56,25 +56,17 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token.
|
||||
|
||||
When an authenticated session is present the token is bound to that user
|
||||
(revocable from Settings → API tokens); otherwise a legacy shared token is
|
||||
created for backward compatibility.
|
||||
"""
|
||||
"""Generate a public API access token (A4 : session obligatoire — plus de
|
||||
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
if user and user.get("id"):
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
@@ -130,7 +130,7 @@ async def share_page(page_id: int, request: Request):
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("share_page")
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
@@ -332,7 +332,7 @@ async def publish_page(page_id: int, request: Request):
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("publish_page")
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -363,7 +363,7 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("unpublish_page")
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -399,7 +399,7 @@ async def track_recent(request: Request):
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
source_type=excluded.source_type,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
||||
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -407,5 +407,5 @@ async def track_recent(request: Request):
|
||||
"status": "tracked",
|
||||
"user_id": user["id"],
|
||||
"page_id": page_id,
|
||||
"accessed_at": datetime.utcnow().isoformat(),
|
||||
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
|
||||
+10
-5
@@ -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']}"}
|
||||
|
||||
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
@@ -250,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;}}
|
||||
|
||||
+2
-2
@@ -9,13 +9,13 @@ import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import wiki
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.notifications import create_notification
|
||||
from app.templating import ENV
|
||||
|
||||
router = APIRouter(tags=["wiki"])
|
||||
|
||||
@@ -156,7 +156,7 @@ async def teamspace_page(teamspace_id: int, request: Request):
|
||||
.ts-row:hover{{background:var(--bg-hover);}}
|
||||
</style>"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
|
||||
+69
-15
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@@ -25,15 +26,36 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
|
||||
def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
|
||||
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
|
||||
promouvoir admin."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row or row["role"] != "admin":
|
||||
raise HTTPException(403, "Workspace admin role required")
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
@@ -58,6 +80,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||
@@ -68,13 +92,14 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
|
||||
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, user_id, role))
|
||||
@@ -84,6 +109,7 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -97,6 +123,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
conn.commit()
|
||||
@@ -133,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"}
|
||||
|
||||
|
||||
@@ -153,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"}
|
||||
|
||||
|
||||
@@ -219,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"}
|
||||
|
||||
|
||||
@@ -466,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"}
|
||||
|
||||
|
||||
@@ -496,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"}
|
||||
|
||||
|
||||
@@ -664,6 +691,7 @@ async def export_csv(request: Request, collection_id: int):
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
@@ -671,12 +699,20 @@ async def list_webhooks(request: Request):
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
@@ -688,6 +724,7 @@ async def create_webhook(request: Request):
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
@@ -698,22 +735,39 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
|
||||
sur le titre de la base ou d'une ligne).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
# 404 explicite : le handler global transformerait un HTTPException(404)
|
||||
# en redirection 302 → login pour un chemin HTML.
|
||||
return HTMLResponse(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
|
||||
"<body><h1>404 — Not found</h1></body></html>",
|
||||
status_code=404,
|
||||
)
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
esc = html.escape
|
||||
name = esc(str(coll["name"] or ""))
|
||||
icon = esc(str(coll["icon"] or ""))
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -24,7 +24,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -168,6 +168,13 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
url = action.get("url", "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a url")
|
||||
# A13 : SSRF — même garde que l'importer URL (loopback/privé refusé).
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
_parsed = _urlparse(url)
|
||||
if _parsed.scheme not in ("http", "https") or not _parsed.hostname or not _is_public_host(_parsed.hostname):
|
||||
raise ValueError(f"webhook url non autorisée: {_parsed.hostname!r}")
|
||||
secret = action.get("secret", "")
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
@@ -413,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
now = now or datetime.now(UTC).replace(tzinfo=None)
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
@@ -481,7 +488,7 @@ async def automation_scheduler():
|
||||
from app.services.workers import run_due_workers
|
||||
await run_due_workers()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("worker cron iteration failed")
|
||||
logger.warning("worker cron iteration failed")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -466,7 +466,7 @@ async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
|
||||
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
||||
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("calendar_sync_scheduler: %s", exc)
|
||||
logger.warning("calendar_sync_scheduler: %s", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,6 +103,34 @@ def parse_og(body: str, url: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
async def _get_checked(client, url: str, headers: dict):
|
||||
"""GET avec re-vérification de l'hôte à CHAQUE saut de redirection (A12 SSRF).
|
||||
|
||||
`follow_redirects=True` laisserait une URL publique rediriger vers
|
||||
169.254.169.254 / localhost — la garde doit donc tourner à chaque hop.
|
||||
"""
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
parsed = urlparse(current)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise ValueError(f"hôte non autorisé: {parsed.hostname!r}")
|
||||
r = await client.get(current, headers=headers, follow_redirects=False)
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
loc = r.headers.get("location")
|
||||
if not loc:
|
||||
return r
|
||||
current = urljoin(current, loc)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r
|
||||
raise ValueError("trop de redirections")
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors.
|
||||
@@ -121,12 +149,14 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
kwargs = {"follow_redirects": True, "timeout": timeout}
|
||||
kwargs = {"timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
resp = await _get_checked(client, src, headers)
|
||||
except ValueError:
|
||||
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
logger.debug("og fetch failed for %s: %s", src, exc)
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document #{pid} introuvable")
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), pid))
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -555,7 +555,7 @@
|
||||
var payload = {prompt: message};
|
||||
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
|
||||
return fetch('/api/agent/generate', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
return resp.json();
|
||||
@@ -701,7 +701,7 @@
|
||||
if(self.llmModel) payload.model = self.llmModel;
|
||||
|
||||
fetch('/api/agent/generate', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
|
||||
if(d && d.ok && d.text){
|
||||
@@ -824,7 +824,7 @@
|
||||
installGallerySkill(slug, icon, name){
|
||||
var self = this;
|
||||
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
|
||||
}).then(function(r){
|
||||
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
|
||||
}).then(function(res){
|
||||
@@ -988,7 +988,7 @@
|
||||
var body = {title:'Nouvelle conversation'};
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -1005,7 +1005,7 @@
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
self._ensuring = fetch('/api/agent/conversations', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -1027,7 +1027,7 @@
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations/'+self.currentConv.id, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).catch(function(){});
|
||||
},
|
||||
|
||||
@@ -1832,7 +1832,7 @@
|
||||
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
|
||||
fetch('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if(d && d.status === 'recorded'){ m.fb = rating; }
|
||||
@@ -1930,7 +1930,7 @@
|
||||
|
||||
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
|
||||
|
||||
@@ -1370,7 +1370,7 @@
|
||||
loadSidebarConfig() {
|
||||
var self = this;
|
||||
try {
|
||||
var raw = {{ sidebar_config|default('{}')|safe }};
|
||||
var raw = {{ sidebar_config|default({})|tojson }};
|
||||
if (raw && raw.config) {
|
||||
self.sidebarConfig = raw.config;
|
||||
} else if (typeof raw === 'object') {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
|
||||
contenteditable="true"
|
||||
@blur="updateField('body', $event.target.innerHTML)">
|
||||
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
|
||||
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Checklists -->
|
||||
@@ -82,7 +82,7 @@
|
||||
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
|
||||
{{ comment.body|safe }}
|
||||
{{ comment.body }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
@@ -150,7 +150,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!item) return;
|
||||
if (!confirm('Delete ' + item.name + '?')) return;
|
||||
var self = this;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
}).then(function(r){
|
||||
if (r.ok) { self.refreshTree(); }
|
||||
@@ -245,7 +245,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!path) return;
|
||||
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
||||
var sha = el.getAttribute('data-gitea-sha') || '';
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
}).then(function(r) {
|
||||
if (r.ok) self.refreshTree();
|
||||
@@ -387,7 +387,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!this.filePath) return;
|
||||
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -1198,7 +1198,7 @@ function libraryPage() {
|
||||
var item = store && store.node;
|
||||
if (!item) return;
|
||||
var self = this;
|
||||
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
|
||||
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return;
|
||||
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
|
||||
@@ -1218,7 +1218,7 @@ function libraryPage() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
|
||||
var self = this;
|
||||
// Soft-delete all selected items
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
||||
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
}
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
@@ -1124,7 +1124,8 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
|
||||
body: JSON.stringify({icon: icon})
|
||||
});
|
||||
if (!r.ok) throw new Error('icon update failed');
|
||||
@@ -1206,7 +1207,7 @@ window._wsInitData = (function() {
|
||||
color = color || (store && store.newTagColor) || '#787774';
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1343,7 +1344,7 @@ window._wsInitData = (function() {
|
||||
if (!newName) return;
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1523,7 +1524,7 @@ window._wsInitData = (function() {
|
||||
this.renamingId = null;
|
||||
if (!n || n === node.name) return;
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: n})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1578,7 +1579,7 @@ window._wsInitData = (function() {
|
||||
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||
this.clipboard.forEach(function(id) {
|
||||
fetch('/api/local-workspace/items/' + id + '/move', {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({parent_id: targetId})
|
||||
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
|
||||
});
|
||||
@@ -1589,7 +1590,7 @@ window._wsInitData = (function() {
|
||||
// ── Duplicate ──
|
||||
async duplicateItem(node) {
|
||||
var r = await fetch('/api/local-workspace/items', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
|
||||
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
|
||||
});
|
||||
@@ -1617,7 +1618,7 @@ window._wsInitData = (function() {
|
||||
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
|
||||
this.undoVisible = true;
|
||||
// Delete via API
|
||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
|
||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||
if (!r.ok) { this.undoVisible = false; return; }
|
||||
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
|
||||
this._reloadAfterAction();
|
||||
@@ -1642,7 +1643,7 @@ window._wsInitData = (function() {
|
||||
self._reloadAfterAction();
|
||||
return;
|
||||
}
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
|
||||
.then(function(r) { if (r.ok) restored++; })
|
||||
.finally(function() { restoreOne(i + 1); });
|
||||
}
|
||||
@@ -1940,7 +1941,7 @@ window._wsInitData = (function() {
|
||||
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
||||
var r = await fetch('/api/local-workspace/items', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2024,7 +2025,7 @@ window._wsInitData = (function() {
|
||||
if (!n||!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:n})
|
||||
});
|
||||
if (r.ok) { this._reloadAfterAction(); }
|
||||
@@ -2037,7 +2038,7 @@ window._wsInitData = (function() {
|
||||
|
||||
async doDelete() {
|
||||
if (!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||
if (r.ok) { this._reloadAfterAction(); }
|
||||
},
|
||||
|
||||
@@ -2230,7 +2231,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2326,7 +2327,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: tagName})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2345,7 +2346,7 @@ window._wsInitData = (function() {
|
||||
|
||||
async removeTag(itemId, tagId) {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2491,7 +2492,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ parent_id: parentId || null })
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
+12
-12
@@ -1337,7 +1337,7 @@ function settingsInit() {
|
||||
var n = this.newTagName.trim();
|
||||
if (!n) return;
|
||||
var r = await fetch('/api/settings/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: n, color: this.newTagColor})
|
||||
});
|
||||
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
||||
@@ -1345,7 +1345,7 @@ function settingsInit() {
|
||||
|
||||
async updateTagColor(id, color) {
|
||||
await fetch('/api/settings/tags/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
await this.loadTags();
|
||||
@@ -1353,7 +1353,7 @@ function settingsInit() {
|
||||
|
||||
async deleteTag(id) {
|
||||
if (!confirm('Delete this tag?')) return;
|
||||
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
||||
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
@@ -1375,7 +1375,7 @@ function settingsInit() {
|
||||
this.renamingTag = null; return;
|
||||
}
|
||||
await fetch('/api/settings/tags/' + tag.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
this.renamingTag = null;
|
||||
@@ -1648,7 +1648,7 @@ function settingsInit() {
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1677,7 +1677,7 @@ function settingsInit() {
|
||||
if (f.models && f.models.length) body.models = f.models;
|
||||
var r = await fetch('/api/agent/keys/' + id, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1713,7 +1713,7 @@ function settingsInit() {
|
||||
if (f.api_key) body.api_key = f.api_key;
|
||||
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1739,7 +1739,7 @@ function settingsInit() {
|
||||
var f = this.keyForm(id);
|
||||
f.deleting = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
|
||||
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
||||
@@ -1899,7 +1899,7 @@ function settingsInit() {
|
||||
if (!file) return;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
|
||||
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||
@@ -1910,7 +1910,7 @@ function settingsInit() {
|
||||
async selectAvatarColor(color) {
|
||||
this.avatarColor = color;
|
||||
var r = await fetch('/api/settings/avatar-color', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
if (r.ok) { this.avatarUrl = ''; }
|
||||
@@ -2055,7 +2055,7 @@ function settingsInit() {
|
||||
// ── v5.2.0 API tokens ──
|
||||
async loadApiTokens() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
|
||||
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.apiTokens = d.tokens || [];
|
||||
} catch(e) { this.apiTokens = []; }
|
||||
@@ -2065,7 +2065,7 @@ function settingsInit() {
|
||||
if (!name) return;
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
|
||||
this.newToken = d;
|
||||
|
||||
@@ -155,7 +155,7 @@ function onboarding() {
|
||||
async createWorkspace() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:this.wsName.trim()})});
|
||||
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({name:this.wsName.trim()})});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
|
||||
this.workspaceId = d.id;
|
||||
@@ -172,7 +172,7 @@ function onboarding() {
|
||||
async createProject() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
|
||||
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
|
||||
this.toast('Projet créé 🎉');
|
||||
|
||||
@@ -174,7 +174,7 @@ function workspacePage() {
|
||||
if (!this.newProjectName.trim()) return;
|
||||
const r = await fetch('/api/workspace/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: this.newProjectName.trim()})
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -214,7 +214,7 @@ function workspacesPage() {
|
||||
},
|
||||
|
||||
async selectLocal(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
|
||||
window.location = '/local-workspace';
|
||||
},
|
||||
|
||||
@@ -222,7 +222,7 @@ function workspacesPage() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
@@ -240,7 +240,7 @@ function workspacesPage() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
@@ -251,7 +251,7 @@ function workspacesPage() {
|
||||
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
|
||||
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
|
||||
await this.load();
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Environment Jinja2 partagé (A10 : autoescape activé partout).
|
||||
|
||||
Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))`
|
||||
sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous
|
||||
les `|safe` du codebase étaient des no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
@@ -772,7 +772,7 @@ EVENTS = [
|
||||
6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements.
|
||||
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
|
||||
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
|
||||
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` généré (402 chemins).
|
||||
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
|
||||
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
|
||||
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
|
||||
|
||||
|
||||
+3092
-46
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,8 @@ jinja2==3.1.*
|
||||
python-multipart==0.0.*
|
||||
pydantic==2.10.*
|
||||
pydantic-settings==2.7.*
|
||||
aiosqlite==0.20.*
|
||||
loguru==0.7.*
|
||||
python-dotenv==1.0.*
|
||||
packaging>=24.0
|
||||
itsdangerous==2.2.*
|
||||
slowapi==0.1.*
|
||||
weasyprint==69.0
|
||||
xhtml2pdf==0.2.*
|
||||
openpyxl==3.1.*
|
||||
|
||||
+73
-1
@@ -5,13 +5,85 @@ suite is safe to run in parallel (``pytest -n auto``): workers never share a
|
||||
database file, and no state leaks between tests.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class _TestSessionAuth(httpx.Auth):
|
||||
"""Session + CSRF injectés à la volée (jamais dans le cookie jar du client).
|
||||
|
||||
- `flowdeck_session` ajouté seulement s'il est absent de la requête (un test
|
||||
peut fournir la sienne via `cookies=`) ;
|
||||
- `csrf_token` idem, et l'en-tête `X-CSRF-Token` suit TOUJOURS le cookie
|
||||
courant (le token tourne quand `/api/csrf-token` est appelé) ;
|
||||
- un test qui veut l'anonymat fait `anon(client)` → `client.auth = None`.
|
||||
"""
|
||||
|
||||
CSRF_FALLBACK = "csrf-test-token"
|
||||
|
||||
def __init__(self, session_token: str):
|
||||
self.session_token = session_token
|
||||
|
||||
def auth_flow(self, request):
|
||||
ch = request.headers.get("cookie", "")
|
||||
add = []
|
||||
if "flowdeck_session=" not in ch:
|
||||
add.append(f"flowdeck_session={self.session_token}")
|
||||
if "csrf_token=" not in ch:
|
||||
add.append(f"csrf_token={self.CSRF_FALLBACK}")
|
||||
if add:
|
||||
request.headers["cookie"] = "; ".join(([ch] if ch else []) + add)
|
||||
if "X-CSRF-Token" not in request.headers:
|
||||
m = re.search(r"csrf_token=([^;]+)", request.headers.get("cookie", ""))
|
||||
if m:
|
||||
request.headers["X-CSRF-Token"] = m.group(1)
|
||||
yield request
|
||||
|
||||
|
||||
def login_test_client(tc, user_id: int = 1, login: str = "tester", is_admin: int = 1):
|
||||
"""Connecte un TestClient (A3–A7 : les routes testées exigent une session)."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,?)",
|
||||
(user_id, login, login.title(), is_admin),
|
||||
)
|
||||
conn.commit()
|
||||
tc.auth = _TestSessionAuth(
|
||||
SessionManager.create_session(
|
||||
{"id": user_id, "login": login, "full_name": login.title(), "is_admin": is_admin}
|
||||
)
|
||||
)
|
||||
return tc
|
||||
|
||||
|
||||
def anon(client):
|
||||
"""Test d'anonymat : plus de session, plus de CSRF par défaut."""
|
||||
client.cookies.clear()
|
||||
client.headers.pop("X-CSRF-Token", None)
|
||||
client.auth = None
|
||||
return client
|
||||
|
||||
|
||||
def anon_csrf(client):
|
||||
"""Anonyme MAIS CSRF valide — comme un navigateur qui a déjà chargé une page.
|
||||
|
||||
Sert aux tests d'"isolation auth" : on veut le 401 de la route, pas le 403
|
||||
du middleware CSRF qui passerait avant.
|
||||
"""
|
||||
anon(client)
|
||||
client.cookies.set("csrf_token", "csrf-anon")
|
||||
client.headers["X-CSRF-Token"] = "csrf-anon"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI TestClient with a fresh temporary SQLite database."""
|
||||
@@ -55,7 +127,7 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -45,7 +46,7 @@ def client():
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -41,7 +42,7 @@ def client():
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
|
||||
+14
-1
@@ -4,6 +4,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import anon, anon_csrf, login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ def client():
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
@@ -88,6 +89,7 @@ def test_board_404(client):
|
||||
|
||||
|
||||
def test_csrf_rejected(client):
|
||||
anon(client)
|
||||
resp = client.post("/api/move?owner=x&repo=y&issue_id=1&column=Test")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -134,6 +136,7 @@ def test_card_detail_html(client):
|
||||
|
||||
|
||||
def test_create_issue_api(client):
|
||||
anon(client)
|
||||
resp = client.post("/api/issues/test/test?title=Test%20Issue&body=Test%20body")
|
||||
# 403 CSRF or 500 if Gitea down
|
||||
assert resp.status_code in (403, 500)
|
||||
@@ -201,11 +204,13 @@ def test_get_ai_keywords(client):
|
||||
|
||||
|
||||
def test_csrf_protects_properties_post(client):
|
||||
anon(client)
|
||||
resp = client.post("/board/api/properties/test/test?name=Priority&prop_type=select")
|
||||
assert resp.status_code == 403 # CSRF
|
||||
|
||||
|
||||
def test_csrf_protects_sync(client):
|
||||
anon(client)
|
||||
resp = client.post("/board/api/sync/test/test")
|
||||
assert resp.status_code == 403 # CSRF
|
||||
|
||||
@@ -1354,6 +1359,7 @@ def _create_regular_session():
|
||||
|
||||
|
||||
def test_admin_list_users_unauthorized(client):
|
||||
anon(client)
|
||||
"""GET /api/admin/users — 403 without admin session."""
|
||||
resp = client.get("/api/admin/users")
|
||||
assert resp.status_code == 403
|
||||
@@ -1588,6 +1594,7 @@ def test_admin_stats(client):
|
||||
|
||||
|
||||
def test_admin_stats_unauthorized(client):
|
||||
anon(client)
|
||||
"""GET /api/admin/stats — 403 for non-admin."""
|
||||
resp = client.get("/api/admin/stats")
|
||||
assert resp.status_code == 403
|
||||
@@ -1663,6 +1670,7 @@ def test_gitea_status_with_expired_token(client):
|
||||
|
||||
|
||||
def test_gitea_disconnect_no_auth(client):
|
||||
anon_csrf(client)
|
||||
"""DELETE /api/gitea/disconnect — 401 without session."""
|
||||
resp = client.delete("/api/gitea/disconnect")
|
||||
assert resp.status_code == 401
|
||||
@@ -1883,6 +1891,7 @@ def test_auth_user_authenticated(client):
|
||||
|
||||
|
||||
def test_auth_user_unauthenticated(client):
|
||||
anon(client)
|
||||
"""GET /auth/user — returns authenticated=false without session."""
|
||||
resp = client.get("/auth/user")
|
||||
assert resp.status_code == 200
|
||||
@@ -1943,6 +1952,7 @@ def test_gitea_private_pages_list_no_auth(client):
|
||||
|
||||
|
||||
def test_gitea_private_pages_create_no_auth(client):
|
||||
anon_csrf(client)
|
||||
"""POST private-pages — 401 without session."""
|
||||
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
|
||||
assert resp.status_code == 401
|
||||
@@ -2019,6 +2029,7 @@ def test_page_renders_breadcrumb_data(client):
|
||||
|
||||
|
||||
def test_landing_page_no_auth(client):
|
||||
anon(client)
|
||||
"""Visiting / without auth shows the landing page."""
|
||||
resp = client.get("/", follow_redirects=False)
|
||||
assert resp.status_code == 200
|
||||
@@ -2095,6 +2106,7 @@ def test_register_duplicate_rejected(client):
|
||||
|
||||
|
||||
def test_session_expired_redirect(client):
|
||||
anon(client)
|
||||
"""Unauthenticated access to protected page redirects with expired param."""
|
||||
resp = client.get("/workspaces", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
@@ -3464,6 +3476,7 @@ def test_v490_page_mentions_endpoint(client):
|
||||
|
||||
|
||||
def test_v490_notifications_require_auth(client):
|
||||
anon(client)
|
||||
r = client.get("/api/notifications")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Non-régression de l'audit sécurité 2026-09-30 — A11 (traversal) et A18 (XSS public)."""
|
||||
from conftest import anon, anon_csrf
|
||||
|
||||
|
||||
def test_avatar_path_traversal_denied(client):
|
||||
"""A11 : `:path` accepte les `/` — la lecture doit rester dans /data/avatars."""
|
||||
r = client.get("/api/settings/avatar/..%2f..%2fetc%2fpasswd")
|
||||
assert r.status_code in (403, 404), r.status_code
|
||||
|
||||
|
||||
def test_public_view_escapes_output(client):
|
||||
"""A18 : titre de base et titre de ligne interpolés dans un f-string HTML."""
|
||||
cid = client.post("/db/api", json={"name": "<script>alert(1)</script>"}).json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "<img src=x onerror=alert(1)>"})
|
||||
|
||||
anon(client)
|
||||
r = client.get(f"/workspace/public/{cid}")
|
||||
assert r.status_code == 200
|
||||
assert "<script>alert(1)" not in r.text
|
||||
assert "<script>" in r.text
|
||||
assert "<img src=x" not in r.text
|
||||
|
||||
|
||||
def test_public_view_hides_restricted_collection(client):
|
||||
"""A18 : `permission_type` restricted/private → 404 (pas de fuite)."""
|
||||
cid = client.post("/db/api", json={"name": "Internal"}).json()["id"]
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE collections SET permission_type='restricted' WHERE id=?", (cid,))
|
||||
conn.commit()
|
||||
|
||||
anon(client)
|
||||
r = client.get(f"/workspace/public/{cid}")
|
||||
assert r.status_code == 404
|
||||
assert "Internal" not in r.text
|
||||
|
||||
|
||||
def test_rate_limit_key_and_prune():
|
||||
"""A33 : XFF ignoré depuis une IP publique (anti-bypass), épurage du store."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.middleware.security import RateLimitMiddleware
|
||||
|
||||
mw = RateLimitMiddleware(None)
|
||||
|
||||
def req(host, fwd=None):
|
||||
headers = {"x-forwarded-for": fwd} if fwd else {}
|
||||
return SimpleNamespace(headers=headers, client=SimpleNamespace(host=host))
|
||||
|
||||
# IP publique (globale) : le client peut spoofer XFF autant qu'il veut → clé d'origine
|
||||
assert mw._client_key(req("8.8.8.8", "1.2.3.4")) == "8.8.8.8"
|
||||
# Derrière un proxy local : on prend le premier hop XFF
|
||||
assert mw._client_key(req("10.0.0.1", "198.51.100.7, 10.0.0.2")) == "198.51.100.7"
|
||||
# Sans XFF / hôte non IP (testserver)
|
||||
assert mw._client_key(req("testserver")) == "testserver"
|
||||
|
||||
# Épurage : les fenêtres expirées sortent du store
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
mw._store["old"] = (now - 3600, 5)
|
||||
mw._store["fresh"] = (now, 1)
|
||||
mw._prune(now)
|
||||
assert "old" not in mw._store and "fresh" in mw._store
|
||||
|
||||
|
||||
def test_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
seen = set()
|
||||
for route in app.routes:
|
||||
for method in getattr(route, "methods", None) or set():
|
||||
if method in ("HEAD", "OPTIONS"):
|
||||
continue
|
||||
key = (method, route.path)
|
||||
assert key not in seen, f"doublon de route: {key}"
|
||||
seen.add(key)
|
||||
|
||||
|
||||
def test_og_metadata_rejects_private_host(client):
|
||||
"""A12 : SSRF — aucun fetch vers loopback/link-local (re-vérif à chaque hop)."""
|
||||
for url in ("http://127.0.0.1/latest/meta-data/", "http://169.254.169.254/x", "http://localhost/x"):
|
||||
r = client.post("/board/api/og/metadata", json={"url": url})
|
||||
assert r.status_code == 400, (url, r.status_code, r.text[:200])
|
||||
|
||||
|
||||
def test_automations_require_session(client):
|
||||
"""A13 : CRUD, run et press-button refusent un anonymous."""
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
assert client.post("/workspace/automations", json={"name": "x"}).status_code == 401
|
||||
assert client.post("/workspace/automations/1/run", json={}).status_code == 401
|
||||
assert client.post("/api/automations/press-button", json={}).status_code == 401
|
||||
assert client.get("/workspace/automations").status_code == 401
|
||||
|
||||
|
||||
def test_outbound_webhook_requires_admin_and_public_url(client):
|
||||
"""A15 : webhooks sortants = admin + URL publique (le scheduler POSTe le contenu)."""
|
||||
# admin de la fixture : URL privée refusée (SSRF)
|
||||
r = client.post("/workspace/webhooks", json={"url": "http://127.0.0.1/hook"})
|
||||
assert r.status_code == 400
|
||||
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
assert client.post("/workspace/webhooks", json={"url": "https://example.com/h"}).status_code == 401
|
||||
|
||||
|
||||
def test_legacy_api_requires_auth(client):
|
||||
"""A17 : le router /api legacy refuse un anonymous (health et front-error restent publics)."""
|
||||
anon(client)
|
||||
assert client.get("/api/users/me").status_code == 401
|
||||
# CSRF valide mais aucune session → la garde du router doit répondre 401.
|
||||
client.cookies.set("csrf_token", "csrf-anon")
|
||||
assert client.post("/api/move", json={}, headers={"X-CSRF-Token": "csrf-anon"}).status_code == 401
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_upload_requires_session_and_validates_files(client):
|
||||
"""A22 : validate_upload branché (taille + extension) et pas d'upload anonyme."""
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
assert validate_upload("note.txt", 10) is None
|
||||
assert validate_upload("virus.exe", 10) is not None
|
||||
assert validate_upload("big.txt", 11 * 1024 * 1024) is not None
|
||||
|
||||
anon_csrf(client)
|
||||
r = client.post("/api/local-workspace/upload", files={"files": ("a.txt", b"x", "text/plain")})
|
||||
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"]
|
||||
|
||||
anon(client)
|
||||
assert client.get(f"/api/export/markdown/{pid}").status_code == 401
|
||||
assert client.get(f"/api/export/html/{pid}").status_code == 401
|
||||
assert client.get(f"/api/pages/{pid}/download").status_code == 401
|
||||
assert client.get(f"/api/pages/{pid}/file-content").status_code == 401
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -31,7 +32,7 @@ def client():
|
||||
conn.commit()
|
||||
|
||||
tc = TestClient(app)
|
||||
yield tc
|
||||
yield login_test_client(tc)
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -34,7 +35,7 @@ def client():
|
||||
conn.commit()
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
yield login_test_client(tc)
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -28,7 +29,7 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import anon, login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
@@ -38,7 +39,7 @@ def client():
|
||||
manager._rooms = {}
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
yield login_test_client(tc)
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
@@ -113,6 +114,7 @@ def test_merge_ops_sequential():
|
||||
# ── Auth & présence de page ──
|
||||
|
||||
def test_ws_requires_auth(client):
|
||||
anon(client)
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
@@ -121,6 +123,7 @@ def test_ws_requires_auth(client):
|
||||
|
||||
|
||||
def test_ws_auth_rejected(client):
|
||||
anon(client)
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
|
||||
@@ -16,6 +16,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import anon, login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
@@ -49,7 +50,7 @@ def client():
|
||||
manager.stat_connections_total = 0
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
yield login_test_client(tc)
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
@@ -193,6 +194,7 @@ def test_apply_op_still_lww_without_base():
|
||||
# ── protocole WS ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_ws_requires_auth(client):
|
||||
anon(client)
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
@@ -392,6 +394,7 @@ def test_ws_stats_endpoint(client):
|
||||
|
||||
|
||||
def test_ws_stats_requires_auth(client):
|
||||
anon(client)
|
||||
r = client.get("/api/realtime/stats")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"error": "unauthorized"}
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -31,7 +32,7 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import anon
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@@ -47,6 +49,7 @@ def test_sw_served_via_static_mount(client):
|
||||
|
||||
|
||||
def test_sw_registration_present_on_landing(client):
|
||||
anon(client)
|
||||
"""Anonymous entry point (/) registers the SW (assets are public)."""
|
||||
resp = client.get("/")
|
||||
assert resp.status_code in (200, 302)
|
||||
@@ -67,6 +70,7 @@ def test_sw_registration_with_background_sync_in_base(client):
|
||||
|
||||
|
||||
def test_base_has_pwa_meta_tags(client):
|
||||
anon(client)
|
||||
resp = client.get("/")
|
||||
body = resp.text
|
||||
assert 'rel="manifest"' in body
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf, login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -41,7 +42,7 @@ def client():
|
||||
conn.commit()
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
yield login_test_client(tc)
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@@ -109,6 +110,7 @@ def test_share_invalid_permission_rejected(client):
|
||||
|
||||
|
||||
def test_share_requires_auth(client):
|
||||
anon_csrf(client)
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"})
|
||||
assert r.status_code == 401
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints."""
|
||||
import time
|
||||
|
||||
from conftest import anon
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -58,6 +60,7 @@ def _page_updated_epoch(pid) -> float:
|
||||
|
||||
|
||||
def test_sync_requires_auth(client):
|
||||
anon(client)
|
||||
assert client.get("/api/v2/sync/status").status_code == 401
|
||||
assert client.post("/api/v2/sync/batch", json={"mutations": []}).status_code == 401
|
||||
assert client.get("/api/v2/sync/delta").status_code == 401
|
||||
|
||||
@@ -13,6 +13,7 @@ import secrets
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -36,7 +37,7 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
@@ -58,7 +59,11 @@ def _login(client):
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0})
|
||||
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
||||
# Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque
|
||||
# appel, un token rendu par un login précédent deviendrait invalide (403).
|
||||
csrf = client.cookies.get("csrf_token")
|
||||
if not csrf:
|
||||
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
||||
return session, csrf, uid
|
||||
|
||||
|
||||
@@ -76,13 +81,17 @@ def _login_admin(client):
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
|
||||
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
||||
# Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque
|
||||
# appel, un token rendu par un login précédent deviendrait invalide (403).
|
||||
csrf = client.cookies.get("csrf_token")
|
||||
if not csrf:
|
||||
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
||||
return session, csrf, uid
|
||||
|
||||
|
||||
def _mk_page(client, session, title, blocks=None):
|
||||
r = client.post("/board/api/pages", params={"title": title, "section": "Private"},
|
||||
cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": "x"})
|
||||
cookies={"flowdeck_session": session})
|
||||
assert r.status_code == 200
|
||||
pid = r.json()["id"]
|
||||
if blocks is not None:
|
||||
|
||||
+11
-1
@@ -8,6 +8,7 @@ import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -66,6 +67,7 @@ def test_api_token_lifecycle(client):
|
||||
|
||||
|
||||
def test_api_tokens_require_authentication(client):
|
||||
anon_csrf(client)
|
||||
r1 = client.get("/api/settings/tokens")
|
||||
assert r1.status_code == 401
|
||||
r2 = client.post("/api/settings/tokens", json={"name": "x"})
|
||||
@@ -88,7 +90,11 @@ def test_sessions_listed_and_revocable(client):
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM user_sessions").fetchone()[0]
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM user_sessions WHERE user_id IN "
|
||||
"(SELECT id FROM users WHERE login IN (?, ?))",
|
||||
("[email protected]", "[email protected]"),
|
||||
).fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
# Revoke alice's session using alice's cookie (the test client now has bob's cookie).
|
||||
@@ -100,6 +106,9 @@ def test_sessions_listed_and_revocable(client):
|
||||
# Create a fresh client with alice's cookie to revoke.
|
||||
client_alice = TestClient(client.app)
|
||||
client_alice.cookies.set("flowdeck_session", alice_cookie)
|
||||
# CSRF aussi sur ce client jetable (la route n'est plus exemptée, A19).
|
||||
client_alice.cookies.set("csrf_token", "csrf-alice")
|
||||
client_alice.headers["X-CSRF-Token"] = "csrf-alice"
|
||||
revoke = client_alice.post(f"/api/settings/sessions/{alice_sid}/revoke")
|
||||
assert revoke.status_code == 200
|
||||
|
||||
@@ -192,6 +201,7 @@ def test_backup_disabled_returns_none(client):
|
||||
|
||||
|
||||
def test_backup_admin_api(client):
|
||||
anon_csrf(client)
|
||||
"""The backup admin API is admin-only and snapshots on demand."""
|
||||
# Unauthenticated → forbidden.
|
||||
assert client.post("/api/settings/backups/run").status_code == 403
|
||||
|
||||
+10
-3
@@ -205,13 +205,20 @@ class TestV54TrashPurge:
|
||||
os.environ["PROJECT_SYNC_ENABLED"] = "false"
|
||||
|
||||
import app.config
|
||||
app.config.settings = app.config.Settings()
|
||||
# In-place mutation, jamais de rebinding : les modules importés plus tôt
|
||||
# (sso_provisioning, trash, …) gardent une référence à l'objet courant.
|
||||
_s = app.config.settings
|
||||
_s.database_url = f"sqlite:///{tmp.name}"
|
||||
_s.app_secret_key = "test-secret"
|
||||
_s.rate_limit_enabled = False
|
||||
_s.backup_enabled = False
|
||||
_s.project_sync_enabled = False
|
||||
init_db()
|
||||
|
||||
with get_conn() as conn:
|
||||
# insert 2 deleted pages: one old, one recent
|
||||
old = datetime.datetime.utcnow() - datetime.timedelta(days=45)
|
||||
recent = datetime.datetime.utcnow() - datetime.timedelta(days=5)
|
||||
old = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=45)
|
||||
recent = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=5)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
|
||||
("test", "Old Page", old.isoformat()),
|
||||
|
||||
+4
-4
@@ -233,7 +233,7 @@ class TestOGParser:
|
||||
return httpx.Response(200, headers={"content-type": "text/html"}, text=_OG_HTML)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
data = asyncio.run(fetch_og_metadata("https://flowdeck.example.com/page", transport=transport))
|
||||
data = asyncio.run(fetch_og_metadata("https://example.com/page", transport=transport))
|
||||
assert data["title"] == "FlowDeck — Notion clone"
|
||||
assert data["site_name"] == "FlowDeck"
|
||||
|
||||
@@ -255,10 +255,10 @@ class TestOGParser:
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
data = asyncio.run(
|
||||
fetch_og_metadata("https://unreachable.example.com", transport=httpx.MockTransport(handler))
|
||||
fetch_og_metadata("https://example.com/unreachable", transport=httpx.MockTransport(handler))
|
||||
)
|
||||
assert data["url"].startswith("https://unreachable.example.com")
|
||||
assert data["title"] == "unreachable.example.com"
|
||||
assert data["url"].startswith("https://example.com/unreachable")
|
||||
assert data["title"] == "example.com"
|
||||
|
||||
|
||||
class TestOGMetadataEndpoint:
|
||||
|
||||
@@ -14,6 +14,8 @@ import secrets
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
@@ -382,6 +384,7 @@ class TestMappingAndWizard:
|
||||
assert props["Code"] == "007"
|
||||
|
||||
def test_wizard_page_requires_auth(self, client):
|
||||
anon(client)
|
||||
r = client.get("/import", follow_redirects=False)
|
||||
assert r.status_code in (302, 307)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import secrets
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -35,7 +36,7 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
@@ -142,8 +143,8 @@ def test_auto_properties_filled_on_create_and_update(client):
|
||||
pv = json.loads(client.get(f"/db/pages/{page_id}/api").json()["property_values_json"])
|
||||
assert pv[str(ct)]
|
||||
assert pv[str(lt)]
|
||||
assert pv[str(cb)]["login"] == "admin"
|
||||
assert pv[str(lb)]["login"] == "admin"
|
||||
assert pv[str(cb)]["login"] == "tester"
|
||||
assert pv[str(lb)]["login"] == "tester"
|
||||
|
||||
created = pv[str(ct)]
|
||||
# Partial update keeps created_time and refreshes last_edited_time.
|
||||
|
||||
@@ -14,6 +14,7 @@ import secrets
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import anon, login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
@@ -265,6 +266,7 @@ def test_user_timezone_endpoints(client):
|
||||
|
||||
|
||||
def test_notifications_unauth(client):
|
||||
anon(client)
|
||||
assert client.get("/api/notifications/timezone").status_code == 401
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ restricted, private), explicit grants (user + group), inheritance, property
|
||||
visibility, user groups and the audit log.
|
||||
"""
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
# ═══════════════ helpers ═══════════════
|
||||
@@ -516,6 +518,7 @@ def test_audit_log_records_changes(client):
|
||||
|
||||
|
||||
def test_permissions_endpoints_require_auth(client):
|
||||
anon(client)
|
||||
assert client.get("/api/v2/pages/1/permissions").status_code == 401
|
||||
assert client.post("/api/v2/pages/1/permissions", json={"user_id": 2, "role": "viewer"}).status_code == 401
|
||||
assert client.get("/api/v2/groups").status_code == 401
|
||||
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
|
||||
from app.services import skill_gallery
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
@@ -50,7 +51,7 @@ def client():
|
||||
conn.commit()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
yield TestClient(app)
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
|
||||
@@ -17,6 +17,7 @@ import secrets as pysecrets
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from conftest import anon
|
||||
|
||||
# ── Mock IdP constants ─────────────────────────────────────────────────────
|
||||
IDP_ENTITY = "https://idp.corp.test/saml/metadata"
|
||||
@@ -289,6 +290,7 @@ def test_providers_endpoint_empty_without_config(client):
|
||||
|
||||
|
||||
def test_config_requires_admin(client):
|
||||
anon(client)
|
||||
assert client.get("/api/v2/sso/config").status_code == 401
|
||||
_admin_session(client)
|
||||
# demote to plain user → 403
|
||||
@@ -1284,7 +1286,7 @@ def test_history_endpoint_requires_admin_and_lists_attempts(client, idp_keypair)
|
||||
assert failures[0]["error_message"]
|
||||
|
||||
# anonymous → 401
|
||||
client.cookies.delete("flowdeck_session")
|
||||
anon(client)
|
||||
assert client.get("/api/v2/sso/history").status_code == 401
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
@@ -126,6 +128,7 @@ def test_site_slug_validation_and_conflict(client):
|
||||
|
||||
|
||||
def test_site_requires_auth(client):
|
||||
anon(client)
|
||||
pid = _make_page("Root")
|
||||
r = client.post("/api/v2/sites", json={"root_page_id": pid})
|
||||
assert r.status_code == 401
|
||||
|
||||
@@ -12,6 +12,8 @@ import math
|
||||
import secrets
|
||||
import struct
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import semantic_search as sem
|
||||
|
||||
@@ -188,6 +190,7 @@ def test_hybrid_finds_by_keyword(client):
|
||||
|
||||
|
||||
def test_hybrid_requires_auth(client):
|
||||
anon(client)
|
||||
r = client.get("/api/v2/search/hybrid?q=test")
|
||||
assert r.status_code == 401
|
||||
|
||||
@@ -265,6 +268,7 @@ def test_ask_offline_with_citations(client):
|
||||
|
||||
|
||||
def test_ask_requires_auth(client):
|
||||
anon(client)
|
||||
r = client.post("/api/v2/search/ask", json={"question": "hi"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from conftest import anon, anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import automations as auto_svc
|
||||
@@ -113,6 +114,7 @@ def test_steps_crud_and_order(client):
|
||||
|
||||
|
||||
def test_steps_validation_and_auth(client):
|
||||
anon_csrf(client)
|
||||
session, _ = _login(client)
|
||||
aid = _mkauto(client, session)
|
||||
r = client.post(f"/workspace/automations/{aid}/steps",
|
||||
@@ -423,6 +425,7 @@ def _mkworker(client, session, **kw):
|
||||
|
||||
|
||||
def test_workers_crud_and_auth(client):
|
||||
anon(client)
|
||||
session, _ = _login(client)
|
||||
w = _mkworker(client, session, name="Hello")
|
||||
assert w["slug"].startswith("hello") or w["slug"]
|
||||
|
||||
@@ -11,6 +11,7 @@ import json
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import calendar_sync as cal
|
||||
@@ -117,6 +118,7 @@ def test_link_crud_and_encryption(client):
|
||||
|
||||
|
||||
def test_link_validation_and_auth(client):
|
||||
anon_csrf(client)
|
||||
session, _ = _login(client)
|
||||
cid = _mkcollection(client)
|
||||
r = client.post("/api/v2/calendar-links",
|
||||
@@ -279,6 +281,7 @@ def test_freebusy_basic(client):
|
||||
|
||||
|
||||
def test_freebusy_validation(client):
|
||||
anon_csrf(client)
|
||||
session, _ = _login(client)
|
||||
cid = _mkcollection(client)
|
||||
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01",
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -289,6 +291,7 @@ def test_2fa_disable(client):
|
||||
|
||||
|
||||
def test_2fa_routes_require_session(client):
|
||||
anon(client)
|
||||
assert client.get("/auth/2fa/status").status_code == 401
|
||||
assert client.post("/auth/2fa/setup").status_code == 401
|
||||
|
||||
@@ -362,6 +365,7 @@ def test_webauthn_register_begin(client):
|
||||
|
||||
|
||||
def test_webauthn_register_begin_requires_session(client):
|
||||
anon(client)
|
||||
assert client.post("/auth/webauthn/register/begin").status_code == 401
|
||||
|
||||
|
||||
@@ -411,6 +415,7 @@ def test_webauthn_keys_empty_and_delete_404(client):
|
||||
# ── unified audit log ──────────────────────────────────────────────────────
|
||||
|
||||
def test_audit_requires_admin(client):
|
||||
anon(client)
|
||||
uid, login = _make_user()
|
||||
c = {"flowdeck_session": _session(uid, login)}
|
||||
assert client.get("/api/v2/audit/logs", cookies=c).status_code == 403
|
||||
@@ -608,5 +613,6 @@ def test_approval_rejection(client):
|
||||
|
||||
|
||||
def test_governance_routes_require_auth(client):
|
||||
anon(client)
|
||||
assert client.get("/api/v2/agent-policies").status_code == 401
|
||||
assert client.get("/api/v2/agent-approvals").status_code == 401
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import secrets
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -133,6 +135,7 @@ def test_teamspace_requires_name(client):
|
||||
|
||||
|
||||
def test_teamspace_requires_auth(client):
|
||||
anon(client)
|
||||
assert client.get("/api/v2/wiki/teamspaces?workspace_id=1").status_code == 401
|
||||
assert client.post("/api/v2/wiki/teamspaces", json={}).status_code == 401
|
||||
|
||||
@@ -548,6 +551,7 @@ def test_guest_share_bad_role(client):
|
||||
|
||||
|
||||
def test_guest_share_requires_session(client):
|
||||
anon(client)
|
||||
assert client.post("/api/v2/wiki/pages/1/guests", json={}).status_code == 401
|
||||
|
||||
|
||||
@@ -749,6 +753,7 @@ def test_blocks_preview_endpoint(client):
|
||||
|
||||
|
||||
def test_blocks_preview_validation(client):
|
||||
anon(client)
|
||||
_, _, c = _user()
|
||||
assert client.post("/api/v2/wiki/blocks/preview", cookies=c, json={}).status_code == 400
|
||||
assert client.post("/api/v2/wiki/blocks/preview", cookies=c,
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from conftest import anon
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
|
||||
@@ -81,6 +83,7 @@ def test_extract_article(client):
|
||||
# ── API tests ──
|
||||
|
||||
def test_clip_requires_auth(client):
|
||||
anon(client)
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "T"})
|
||||
assert r.status_code == 401
|
||||
|
||||
@@ -225,7 +228,7 @@ def test_clip_bearer_token_auth(client):
|
||||
conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?,?,?,?)", (uid, "test", th, token[:12]))
|
||||
conn.commit()
|
||||
# No session cookie
|
||||
client.cookies.clear()
|
||||
anon(client)
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/bearer", "title": "BearerClip", "content": "<p>via bearer</p>", "device_id": "bearer-dev"},
|
||||
@@ -241,7 +244,7 @@ def test_status_and_devices_flow(client):
|
||||
uid = _insert_user("clip_status")
|
||||
_insert_workspace(uid)
|
||||
# unauth status
|
||||
client.cookies.clear()
|
||||
anon(client)
|
||||
r = client.get("/api/v2/web-clipper/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["authenticated"] is False
|
||||
|
||||
Reference in New Issue
Block a user