fix: A29 + A42(partiel) — publish partagé, fuite password_hash, data_dir (v7.5.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
  404 si la page n'existe pas, événements centralisés. Les 3 paires
  publish/unpublish déléguent (sharing = front, board, v2) :
  · board : mise à jour aveugle → 404 + contrôle de session ajouté
  · board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
    le share dialog reste l'unique propriétaire de ces drapeaux
  · v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
  · `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
    décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
  faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
  exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
  → `settings.data_dir` (property : lecture à chaque accès, les tests
  monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
  écriture. Reste : client httpx partagé (52 créations, cache par event loop)

tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired

suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
This commit is contained in:
2026-10-01 10:01:39 -04:00
parent 937ecfc2e0
commit 7be96f0618
19 changed files with 258 additions and 126 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
### Changed
- **A29** — `services/publish.py` partagé : les 3 paires publish/unpublish
(sharing = front, board, v2) déléguent ; 404 partout (board faisait une
mise à jour aveugle), slugify titré unique (board : aléatoire ; v2 : slug
fourni conservé), événements centralisés, board gagne le contrôle de session.
Les bonus divergents disparaissent (`share_mode='anyone'` pour board,
`is_shared=1` pour v2) : le share dialog reste l'unique propriétaire de ces
drapeaux, dépublier ne révoque donc pas un partage manuel. Les listings
`/users/me` ×2 et collections ×3 restent : contrats versionnés distincts
- **A42** — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` →
`settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à
chaque écriture (il ne pouvait que grandir) ; les 29 `Environment(...)`
étaient déjà couverts par A10. **Reste** : client httpx partagé (52 créations,
à faire avec un cache par event loop)
### Security
- **Byproduct A29** — `GET /api/users/me` (v1) et le contexte Jinja de
`/accounts` renvoyaient `SELECT *` sur `users` : **password_hash**,
`login_attempts` et `locked_until` exposés → colonnes whitelistées
(identiques à la liste v2)
### Tests
- `test_publish_service_shared_and_safe` (slug, 404, partage préservé),
`test_users_me_no_secret_columns`, `test_gitea_cache_evicts_expired`
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41 ## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
### Changed ### Changed
+3 -3
View File
@@ -1149,7 +1149,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [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**.* - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.* - [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* - [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.* - [x] **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.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.* - [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.* - [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
@@ -1165,7 +1165,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch. - [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch.
- [x] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.* - [x] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.* - [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.* - [x] **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é.* — **fait 2026-10-01 (partiel)** : les 29 `Environment(...)` = A10 ✓ ; les 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` → `settings.data_dir` (property, lecture à chaque accès car les tests monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque écriture. **Reste** : le client httpx partagé (52 créations — un `AsyncClient` module-level est fragile avec les event loops de tests, à faire avec un cache par loop). Effort : **M**.
- [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **reste** : `__CSRF_PLACEHOLDER__` (base.html, fenêtre de course JS) et le re-parse JSON de la palette par frappe : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.* - [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **reste** : `__CSRF_PLACEHOLDER__` (base.html, fenêtre de course JS) et le re-parse JSON de la palette par frappe : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
### ✅ Vérifié non-problème (ne pas re-checker) ### ✅ Vérifié non-problème (ne pas re-checker)
@@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. → **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0.
+1 -1
View File
@@ -1 +1 @@
7.4.0 7.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.4.0 (audit — A30/A37/A39/A40/A41) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.5.0 (audit — A29/A42 partiel) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global ## Avancement Global
+10
View File
@@ -1,12 +1,22 @@
"""FlowDeck — Configuration via pydantic-settings.""" """FlowDeck — Configuration via pydantic-settings."""
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
@property
def data_dir(self) -> str:
"""Racine des fichiers (avatars, uploads…).
Pas un champ : la lecture est faite à chaque accès parce que les tests
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
"""
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore" env_file=".env", env_file_encoding="utf-8", extra="ignore"
) )
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.4.0", version="7.5.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+6 -1
View File
@@ -551,8 +551,13 @@ async def get_my_profile(request: Request):
if not user: if not user:
return {"login": "guest", "full_name": "Guest", "email": ""} return {"login": "guest", "full_name": "Guest", "email": ""}
with get_conn() as conn: with get_conn() as conn:
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
# password_hash, login_attempts et locked_until.
row = conn.execute( row = conn.execute(
"SELECT * FROM users WHERE login=?", (user.get("login", ""),) "SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, last_login, created_at "
"FROM users WHERE login=?",
(user.get("login", ""),),
).fetchone() ).fetchone()
if row: if row:
return dict(row) return dict(row)
+6 -17
View File
@@ -31,6 +31,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
validate_scopes_input, validate_scopes_input,
) )
from app.services.automations import fire_event as _fire_event from app.services.automations import fire_event as _fire_event
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2", tags=["api-v2"]) router = APIRouter(prefix="/api/v2", tags=["api-v2"])
@@ -1633,30 +1634,18 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
slug = (body.get("slug") or body.get("publish_slug") or f"p-{page_id}-{secrets.token_urlsafe(6)}").strip() slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
with get_conn() as conn: slug, _title = publish(page_id, explicit_slug=slug_in)
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute("UPDATE pages SET is_published=1, publish_slug=?, is_shared=1 WHERE id=?", (slug, page_id))
conn.commit()
audit_log(user, "page.publish", "page", page_id, slug, request) audit_log(user, "page.publish", "page", page_id, slug, request)
try: await fire_published(page_id, slug)
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page_v2")
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"} return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish") @router.delete("/pages/{page_id}/publish")
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization) user = require_scope("write")(request, authorization)
_v2_rate_check(request, user) _v2_rate_check(request, user)
with get_conn() as conn: unpublish(page_id)
conn.execute("UPDATE pages SET is_published=0 WHERE id=?", (page_id,)) await fire_unpublished(page_id)
conn.commit()
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page_v2")
return {"page_id": page_id, "status": "unpublished"} return {"page_id": page_id, "status": "unpublished"}
# ── History ─────────────────────────────────────────────────────────────── # ── History ───────────────────────────────────────────────────────────────
+12 -26
View File
@@ -10,12 +10,14 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn from app.db import get_conn
from app.routers.dashboard import _get_app_version from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event from app.services.automations import fire_event
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board") router = APIRouter(tags=["board"], prefix="/board")
@@ -1011,35 +1013,20 @@ async def update_share(request: Request, page_id: int):
@router.post("/api/pages/{page_id:int}/publish") @router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int): async def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug).""" """Publish a page to the web (generates publish_slug)."""
import secrets if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
slug = "p-" + secrets.token_urlsafe(8) raise HTTPException(401, "Authentication required")
with get_conn() as conn: slug, title = publish(page_id)
conn.execute( await fire_published(page_id, slug)
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?", return {"is_published": True, "publish_slug": slug, "title": title}
(slug, page_id),
)
conn.commit()
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page")
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@router.delete("/api/pages/{page_id:int}/publish") @router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int): async def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web.""" """Unpublish a page from the web."""
with get_conn() as conn: if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
conn.execute( raise HTTPException(401, "Authentication required")
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", unpublish(page_id)
(page_id,), await fire_unpublished(page_id)
)
conn.commit()
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page")
return {"is_published": False} return {"is_published": False}
@@ -1686,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
def _upload_root() -> Path: def _upload_root() -> Path:
import os return Path(settings.data_dir)
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _ws_id_for(request: Request, page_id: int) -> int: def _ws_id_for(request: Request, page_id: int) -> int:
+9 -9
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea from app.services.gitea_client import get_user_gitea_client, gitea
@@ -440,7 +441,10 @@ async def accounts_page(request: Request):
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn: with get_conn() as conn:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall() users = conn.execute(
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
).fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html") template = env.get_template("accounts.html")
return template.render(**ctx) return template.render(**ctx)
@@ -1143,9 +1147,8 @@ def _file_page_disk_path(page: dict):
parts = rel.split("/") parts = rel.split("/")
if ".." in parts or "." in parts: if ".." in parts or "." in parts:
return None return None
import os as _os
from pathlib import Path from pathlib import Path
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve() root = Path(settings.data_dir).resolve()
full = (root / rel).resolve() full = (root / rel).resolve()
try: try:
full.relative_to(root) full.relative_to(root)
@@ -1508,9 +1511,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
async def serve_uploaded_file(ws_id: int, filename: str): async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk.""" """Serve an uploaded file from disk."""
import mimetypes import mimetypes
import os
from pathlib import Path from pathlib import Path
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) root = Path(settings.data_dir)
base_dir = (root / f"uploads/workspace_{ws_id}").resolve() base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve() fp = (base_dir / filename).resolve()
try: try:
@@ -1568,11 +1570,10 @@ async def upload_local_workspace_file(request: Request):
if not files: if not files:
return JSONResponse({"error": "No files provided"}, status_code=400) return JSONResponse({"error": "No files provided"}, status_code=400)
import os
from app.middleware.security import validate_upload from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}" upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True) upload_dir.mkdir(parents=True, exist_ok=True)
@@ -1656,11 +1657,10 @@ async def upload_local_workspace_folder(request: Request):
except json.JSONDecodeError: except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400) return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
import os
from app.middleware.security import validate_upload from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}" upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True) upload_dir.mkdir(parents=True, exist_ok=True)
+2 -2
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn from app.db import get_conn
router = APIRouter(tags=["emojis"]) router = APIRouter(tags=["emojis"])
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
def _upload_root() -> Path: def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) return Path(settings.data_dir)
def _active_ws(request: Request) -> int: def _active_ws(request: Request) -> int:
+5 -58
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import unicodedata
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@@ -11,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
from app.services.automations import fire_event as _fire_event from app.services.automations import fire_event as _fire_event
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api") router = APIRouter(tags=["sharing"], prefix="/api")
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
return user return user
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a page title."""
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
# ── Page Sharing ── # ── Page Sharing ──
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
async def publish_page(page_id: int, request: Request): async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug.""" """Publish a page (is_published=1) with a URL slug."""
_require_auth(request) _require_auth(request)
slug, _title = publish(page_id)
with get_conn() as conn: await fire_published(page_id, slug)
page = conn.execute(
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = _slugify(page["title"])
# Ensure uniqueness by appending suffix if needed
base_slug = slug
counter = 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base_slug}-{counter}"
counter += 1
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
(slug, page_id),
)
conn.commit()
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page")
return { return {
"page_id": page_id, "page_id": page_id,
"is_published": True, "is_published": True,
@@ -346,25 +310,8 @@ async def publish_page(page_id: int, request: Request):
async def unpublish_page(page_id: int, request: Request): async def unpublish_page(page_id: int, request: Request):
"""Unpublish a page.""" """Unpublish a page."""
_require_auth(request) _require_auth(request)
unpublish(page_id)
with get_conn() as conn: await fire_unpublished(page_id)
page = conn.execute(
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page")
return { return {
"page_id": page_id, "page_id": page_id,
"is_published": False, "is_published": False,
+2 -2
View File
@@ -17,12 +17,12 @@ from __future__ import annotations
import io import io
import json import json
import os
import re import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from app.config import settings
from app.db import get_conn from app.db import get_conn
# ═══════════════ Helpers ═══════════════ # ═══════════════ Helpers ═══════════════
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
def _data_root() -> Path: def _data_root() -> Path:
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data).""" """Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) return Path(settings.data_dir)
def _file_meta(page: dict) -> dict: def _file_meta(page: dict) -> dict:
+4 -1
View File
@@ -35,7 +35,10 @@ class GiteaClient:
return None return None
def _set_cache(self, key: str, value: Any) -> None: def _set_cache(self, key: str, value: Any) -> None:
self._cache[key] = (datetime.now() + self._ttl, value) now = datetime.now()
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
self._cache[key] = (now + self._ttl, value)
# ── repos ── # ── repos ──
+2 -2
View File
@@ -10,11 +10,11 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging import logging
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from app.config import settings
from app.db import get_conn from app.db import get_conn
from app.services.db_templates import materialize_properties from app.services.db_templates import materialize_properties
from app.services.export import markdown_to_blocks from app.services.export import markdown_to_blocks
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _data_dir() -> Path: def _data_dir() -> Path:
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) return Path(settings.data_dir)
def _safe_filename(name: str) -> str: def _safe_filename(name: str) -> str:
+2 -1
View File
@@ -16,6 +16,7 @@ import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from app.config import settings
from app.db import get_conn from app.db import get_conn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ class TranscriptionUnavailable(RuntimeError):
def meetings_dir() -> Path: def meetings_dir() -> Path:
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) root = Path(settings.data_dir)
d = root / "uploads" / "meetings" d = root / "uploads" / "meetings"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
+91
View File
@@ -0,0 +1,91 @@
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
Les trois surfaces divergeaient avant cette passe :
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
"""
from __future__ import annotations
import logging
import re
import secrets
import unicodedata
from fastapi import HTTPException
from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
def slugify(title: str) -> str:
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug
def _unique_slug(conn, page_id: int, title: str) -> str:
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
slug, counter = base, 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base}-{counter}"
counter += 1
return slug
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
with get_conn() as conn:
page = conn.execute(
"SELECT id, title FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
def unpublish(page_id: int) -> None:
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
async def fire_published(page_id: int, slug: str) -> None:
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish page.published")
async def fire_unpublished(page_id: int) -> None:
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("publish page.unpublished")
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.4.0" "version": "7.5.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
+69
View File
@@ -94,6 +94,75 @@ def test_asset_version_single_source():
assert literals == [], literals assert literals == [], literals
def test_publish_service_shared_and_safe(client):
"""A29 : les 3 routers déléguent — 404 sur page absente, slug unique,
dépublication qui ne touche pas aux partages manuels."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, share_mode) "
"VALUES (1, 'Publie moi', 'contenu', 'markdown', 'anyone')",
)
pid = cur.lastrowid
conn.commit()
try:
r = client.post(f"/api/pages/{pid}/publish")
assert r.status_code == 200, r.text
slug = r.json()["publish_slug"]
assert slug # slugify du titre
with get_conn() as conn:
row = conn.execute(
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["is_published"] == 1 and row["publish_slug"] == slug
assert row["share_mode"] == "anyone" # intouché (share dialog propriétaire)
r2 = client.delete(f"/api/pages/{pid}/publish")
assert r2.status_code == 200
with get_conn() as conn:
row = conn.execute(
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["is_published"] == 0 and row["publish_slug"] == ""
assert row["share_mode"] == "anyone" # dépublier ne révoque pas le partage
# 404 sur page inexistante — les deux chemins passent par le service
assert client.post("/api/pages/999999/publish").status_code == 404
assert client.delete("/api/pages/999999/publish").status_code == 404
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_users_me_no_secret_columns(client):
"""A29-byproduct : GET /api/users/me (v1) ne doit plus renvoyer password_hash."""
r = client.get("/api/users/me")
assert r.status_code == 200, r.text
body = r.json()
assert "password_hash" not in body, list(body)
assert "locked_until" not in body and "login_attempts" not in body
assert body.get("login") # la réponse reste exploitable
def test_gitea_cache_evicts_expired():
"""A42 : les entrées expirées sortent du cache à chaque écriture."""
from datetime import datetime, timedelta
from app.services.gitea_client import GiteaClient
c = GiteaClient.__new__(GiteaClient) # sans appel réseau
c._cache = {}
c._ttl = timedelta(seconds=1)
c._set_cache("k", "v")
assert c._cached("k") == "v"
# expire l'entrée puis force une autre écriture → la précédente est évacuée
c._cache["k"] = (datetime.now() - timedelta(seconds=1), "v")
c._set_cache("k2", "v2")
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
def test_no_duplicate_routes(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app