fix: A29 + A42(partiel) — publish partagé, fuite password_hash, data_dir (v7.5.0)
- 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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user