fix: A26 + A33 + A34 + A35 + A36 + A43 — bande S/XS du P2 (v7.3.9)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
  produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
  PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
  non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
  (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
  proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
  après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
  (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
  le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
  loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
  CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
  naïf identique, warnings de dépréciation divisés : 2374 → 926)

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
This commit is contained in:
2026-10-01 08:50:39 -04:00
parent ffa1fa89ab
commit cb47f5c7f4
25 changed files with 3289 additions and 109 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
DEFAULT_LANG=fr DEFAULT_LANG=fr
# ── Database ── # ── Database ──
# SQLite (default): sqlite:////data/flowdeck.db # SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck # schéma retombe silencieusement sur /data/flowdeck.db).
DATABASE_URL=sqlite:////data/flowdeck.db DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ── # ── Sync ──
+29
View File
@@ -1,5 +1,34 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
la valeur par défaut (il signe les sessions)
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
(croissance mémoire bornée)
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
scheduler passés en `warning`
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
dupliqué retiré du ROADMAP
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
(format ISO naïf identique, zéro changement de comportement)
### Notes
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
l'alignement à 3.13 implique un rebuild d'image à valider
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel ## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
### Fixed ### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users. Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline > **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
## Quick Start ## Quick Start
+7 -11
View File
@@ -1049,10 +1049,6 @@ Détails livrés :
--- ---
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note | | Feature | Fichiers | Note |
@@ -1150,17 +1146,17 @@ Quality DB views, Agent IA Palette → Realtime + E
### 🟡 P2 — Moyennes ### 🟡 P2 — Moyennes
- [ ] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.* - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.* - [ ] **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**.* - [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* - [ ] **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**.*
- [ ] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.* - [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.* - [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [ ] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**.* - [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
- [ ] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.* - [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.* - [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
### 🟢 P3 — Basses / hygiène ### 🟢 P3 — Basses / hygiène
@@ -1170,7 +1166,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.* - [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.* - [ ] **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**.* - [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
- [ ] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.* - [x] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
### ✅ Vérifié non-problème (ne pas re-checker) ### ✅ Vérifié non-problème (ne pas re-checker)
@@ -1185,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.** → **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9.**
+1 -1
View File
@@ -1 +1 @@
7.3.8 7.3.9
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.8 (audit sécurité — A25 + A21 partiel) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **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
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
@@ -31,7 +31,7 @@ class SessionManager:
""" """
payload = { payload = {
"user": user_data, "user": user_data,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
} }
user_id = user_data.get("id") user_id = user_data.get("id")
if user_id: if user_id:
@@ -94,7 +94,7 @@ class SessionManager:
sid = SessionManager.session_id(cookie) if cookie else None sid = SessionManager.session_id(cookie) if cookie else None
payload = { payload = {
"user": user_data, "user": user_data,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
} }
user_id = user_data.get("id") user_id = user_data.get("id")
if user_id: if user_id:
+3 -4
View File
@@ -22,9 +22,6 @@ class Settings(BaseSettings):
github_oauth_client_id: str = "" github_oauth_client_id: str = ""
github_oauth_client_secret: str = "" github_oauth_client_secret: str = ""
# Standalone mode
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*), # OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub) # set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
oauth_redirect_uri: str = "" oauth_redirect_uri: str = ""
@@ -129,7 +126,9 @@ class Settings(BaseSettings):
import re import re
if re.match(r'^[a-zA-Z]:', p): if re.match(r'^[a-zA-Z]:', p):
return Path(p) return Path(p)
return Path("/" + p) # A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
return Path("/" + p.lstrip("/"))
return Path("/data/flowdeck.db") return Path("/data/flowdeck.db")
+42 -10
View File
@@ -71,6 +71,31 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _spawn(name: str, factory):
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
Loggue l'exception puis recrée la coroutine 10 s plus tard.
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
le bruit devient un problème.
"""
async def _guard():
while True:
try:
await factory()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
await asyncio.sleep(10)
else:
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
await asyncio.sleep(10)
return asyncio.create_task(_guard())
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
init_db() init_db()
@@ -81,6 +106,13 @@ async def lifespan(_app: FastAPI):
from app.db import get_conn from app.db import get_conn
from app.password_utils import hash_password from app.password_utils import hash_password
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
if settings.app_secret_key == "change-me-to-random":
raise RuntimeError(
"APP_SECRET_KEY non défini — générer une valeur : "
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
)
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au # A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent. # premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
with get_conn() as conn: with get_conn() as conn:
@@ -99,41 +131,41 @@ async def lifespan(_app: FastAPI):
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ── # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler()) scheduler_task = _spawn("agent_scheduler", agent_scheduler)
# ── Automations (v5.1.0): cron trigger scheduler ── # ── Automations (v5.1.0): cron trigger scheduler ──
from app.services.automations import automation_scheduler from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler()) automation_task = _spawn("automation_scheduler", automation_scheduler)
# ── Backups (v5.2.0): automatic daily SQLite snapshot ── # ── Backups (v5.2.0): automatic daily SQLite snapshot ──
from app.services.backup import backup_scheduler from app.services.backup import backup_scheduler
backup_task = asyncio.create_task(backup_scheduler()) backup_task = _spawn("backup_scheduler", backup_scheduler)
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ── # ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
from app.services.projects import project_sync_scheduler from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler()) projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ── # ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler()) trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
# ── Reminders (v5.8.0): due-reminder scan for database rows ── # ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler()) reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
# ── Semantic search (v6.9.0): incremental vector indexing ── # ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler from app.services.semantic_search import semantic_index_scheduler
semantic_task = asyncio.create_task(semantic_index_scheduler()) semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
# ── Calendar sync (v7.1.0): external calendars every 15 min ── # ── Calendar sync (v7.1.0): external calendars every 15 min ──
from app.services.calendar_sync import calendar_sync_scheduler from app.services.calendar_sync import calendar_sync_scheduler
calendar_task = asyncio.create_task(calendar_sync_scheduler()) calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
# ── Webhooks outbound (v6.4.0): retry failed deliveries ── # ── Webhooks outbound (v6.4.0): retry failed deliveries ──
from app.services.webhook_outbound import webhook_retry_scheduler from app.services.webhook_outbound import webhook_retry_scheduler
webhook_task = None webhook_task = None
if settings.webhook_retry_enabled: if settings.webhook_retry_enabled:
webhook_task = asyncio.create_task(webhook_retry_scheduler()) webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port) logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
try: try:
@@ -153,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.3.8", version="7.3.9",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+56 -6
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting.""" """FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations from __future__ import annotations
import ipaddress
import time import time
from collections import defaultdict from collections import defaultdict
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Paths that should be rate-limited # Paths that should be rate-limited
RATE_LIMITED_PREFIXES: tuple[str, ...] = ( RATE_LIMITED_PREFIXES: tuple[str, ...] = (
"/api/", "/board/api/", "/auth/", "/api/", "/board/api/", "/auth/",
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
# API workspace + collections (les endpoints mutants du legacy).
"/scim/v2/", "/workspace/", "/db/",
) )
# Pages publiques : seul le non-GET est plafonné (brute force de
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
# visiteurs d'un site publié qui partagent une IP.
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
# Paths exempt from rate limiting even under an API prefix # Paths exempt from rate limiting even under an API prefix
EXEMPT_PATHS: frozenset[str] = frozenset({ EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/health", "/api/health",
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"/api/frontend-errors", "/api/frontend-errors",
}) })
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60): def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
super().__init__(app) super().__init__(app)
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0)) self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
self._last_prune = 0.0
self._max_keys = 5000
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
path = request.url.path path = request.url.path
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
if not settings.rate_limit_enabled: if not settings.rate_limit_enabled:
return await call_next(request) return await call_next(request)
# Only rate-limit API routes # Only rate-limit API routes (+ non-GET sur les pages publiques)
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES): method = request.method.upper()
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
method not in ("GET", "HEAD", "OPTIONS")
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
)
if not limited:
return await call_next(request) return await call_next(request)
# Exempt health check and error capture # Exempt health check and error capture
if path in self.EXEMPT_PATHS: if path in self.EXEMPT_PATHS:
return await call_next(request) return await call_next(request)
ip = request.client.host if request.client else "unknown" limit = self.max_requests or settings.rate_limit_requests
ip = self._client_key(request)
now = time.time() now = time.time()
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
self._prune(now)
window_start, count = self._store[ip] window_start, count = self._store[ip]
if now - window_start > self.window_seconds: if now - window_start > self.window_seconds:
self._store[ip] = (now, 1) self._store[ip] = (now, 1)
return await call_next(request) return await call_next(request)
if count >= self.max_requests: if count >= limit:
return JSONResponse( return JSONResponse(
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"}, {"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
status_code=429, status_code=429,
) )
self._store[ip] = (window_start, count + 1) self._store[ip] = (window_start, count + 1)
return await call_next(request) return await call_next(request)
def _client_key(self, request: Request) -> str:
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
globales, pas seulement RFC1918) — un pair non-global n'est pas un
internaute, donc le XFF du proxy fait foi.
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
exposée directement), prendre la dernière adresse non privée de la
chaîne plutôt que la première.
"""
host = request.client.host if request.client else "unknown"
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
try:
direct = ipaddress.ip_address(host)
if direct.is_private or direct.is_loopback:
return fwd.split(",")[0].strip() or host
except ValueError:
pass # hôte non-IP (testserver…) → on garde la clé d'origine
return host
def _prune(self, now: float) -> None:
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
for k in expired:
del self._store[k]
self._last_prune = now
+2 -1
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
triggers = conn.execute( triggers = conn.execute(
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1" "SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
).fetchall() ).fetchall()
now = datetime.utcnow() now = datetime.now(UTC).replace(tzinfo=None)
for trig in triggers: for trig in triggers:
last = trig["last_fired_at"] last = trig["last_fired_at"]
if last: if last:
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -46,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
if not settings.rate_limit_enabled: if not settings.rate_limit_enabled:
return True return True
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
now = datetime.utcnow().timestamp() now = datetime.now(UTC).replace(tzinfo=None).timestamp()
window_start, count = _rate_limit_store.get(ip, (0, 0)) window_start, count = _rate_limit_store.get(ip, (0, 0))
if now - window_start > 60: if now - window_start > 60:
_rate_limit_store[ip] = (now, 1) _rate_limit_store[ip] = (now, 1)
+2 -2
View File
@@ -1732,7 +1732,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin" ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}: if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format") raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}" folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}" final = f"{stamp}_{name}"
@@ -2040,7 +2040,7 @@ async def delete_page(request: Request, page_id: int):
if not row: if not row:
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
import datetime import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,)) conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit() conn.commit()
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}) await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
return {"status": "ok", "deleted": page_id, "title": row["title"]} return {"status": "ok", "deleted": page_id, "title": row["title"]}
+2 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -1485,7 +1486,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL", "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), item_id), (datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
) )
conn.commit() conn.commit()
return {"status": "ok"} return {"status": "ok"}
+1 -1
View File
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
if ext not in _IMAGE_EXTS: if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format") raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request) ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f") stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}" folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}" final = f"emoji_{stamp}_{safe}"
+3 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
import re import re
import unicodedata import unicodedata
from datetime import datetime from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@@ -399,7 +399,7 @@ async def track_recent(request: Request):
DO UPDATE SET workspace=excluded.workspace, DO UPDATE SET workspace=excluded.workspace,
source_type=excluded.source_type, source_type=excluded.source_type,
accessed_at=excluded.accessed_at""", accessed_at=excluded.accessed_at""",
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()), (user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
) )
conn.commit() conn.commit()
@@ -407,5 +407,5 @@ async def track_recent(request: Request):
"status": "tracked", "status": "tracked",
"user_id": user["id"], "user_id": user["id"],
"page_id": page_id, "page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(), "accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
} }
+3 -3
View File
@@ -24,7 +24,7 @@ import asyncio
import json import json
import logging import logging
import time import time
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx import httpx
@@ -420,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
expr = (expression or "").strip().lower() expr = (expression or "").strip().lower()
if not expr: if not expr:
return False return False
now = now or datetime.utcnow() now = now or datetime.now(UTC).replace(tzinfo=None)
minute = now.minute minute = now.minute
fields = expr.split() fields = expr.split()
@@ -488,7 +488,7 @@ async def automation_scheduler():
from app.services.workers import run_due_workers from app.services.workers import run_due_workers
await run_due_workers() await run_due_workers()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.debug("worker cron iteration failed") logger.warning("worker cron iteration failed")
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed") logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60) await asyncio.sleep(60)
+1 -1
View File
@@ -466,7 +466,7 @@ async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
except Exception as exc: # noqa: BLE001 — one link must not kill the loop except Exception as exc: # noqa: BLE001 — one link must not kill the loop
logger.debug("calendar sync link %s failed: %s", link_id, exc) logger.debug("calendar sync link %s failed: %s", link_id, exc)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug("calendar_sync_scheduler: %s", exc) logger.warning("calendar_sync_scheduler: %s", exc)
await asyncio.sleep(interval_seconds) await asyncio.sleep(interval_seconds)
+2 -1
View File
@@ -17,6 +17,7 @@ import json
import logging import logging
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC
from typing import Any from typing import Any
from app.db import get_conn from app.db import get_conn
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
return ToolResult(status="error", tool=self.name, return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable") message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid)) (datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
conn.commit() conn.commit()
return ToolResult( return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid, status="success", tool=self.name, target_type="document", target_id=pid,
+2 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging import logging
import re import re
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from app.db import get_conn from app.db import get_conn
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
Returns a summary of what was purged. Returns a summary of what was purged.
""" """
cutoff = datetime.utcnow() - timedelta(days=days) cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
purged: list[int] = [] purged: list[int] = []
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
+1 -1
View File
@@ -772,7 +772,7 @@ EVENTS = [
6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements. 6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements.
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin. 7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE). 8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` généré (402 chemins). 9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin. 10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`. 11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
+3091 -45
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -5,12 +5,8 @@ jinja2==3.1.*
python-multipart==0.0.* python-multipart==0.0.*
pydantic==2.10.* pydantic==2.10.*
pydantic-settings==2.7.* pydantic-settings==2.7.*
aiosqlite==0.20.*
loguru==0.7.*
python-dotenv==1.0.* python-dotenv==1.0.*
packaging>=24.0
itsdangerous==2.2.* itsdangerous==2.2.*
slowapi==0.1.*
weasyprint==69.0 weasyprint==69.0
xhtml2pdf==0.2.* xhtml2pdf==0.2.*
openpyxl==3.1.* openpyxl==3.1.*
+29
View File
@@ -36,6 +36,35 @@ def test_public_view_hides_restricted_collection(client):
assert "Internal" not in r.text assert "Internal" not in r.text
def test_rate_limit_key_and_prune():
"""A33 : XFF ignoré depuis une IP publique (anti-bypass), épurage du store."""
from types import SimpleNamespace
from app.middleware.security import RateLimitMiddleware
mw = RateLimitMiddleware(None)
def req(host, fwd=None):
headers = {"x-forwarded-for": fwd} if fwd else {}
return SimpleNamespace(headers=headers, client=SimpleNamespace(host=host))
# IP publique (globale) : le client peut spoofer XFF autant qu'il veut → clé d'origine
assert mw._client_key(req("8.8.8.8", "1.2.3.4")) == "8.8.8.8"
# Derrière un proxy local : on prend le premier hop XFF
assert mw._client_key(req("10.0.0.1", "198.51.100.7, 10.0.0.2")) == "198.51.100.7"
# Sans XFF / hôte non IP (testserver)
assert mw._client_key(req("testserver")) == "testserver"
# Épurage : les fenêtres expirées sortent du store
import time
now = time.time()
mw._store["old"] = (now - 3600, 5)
mw._store["fresh"] = (now, 1)
mw._prune(now)
assert "old" not in mw._store and "fresh" in mw._store
def test_no_duplicate_routes(): 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
+2 -2
View File
@@ -217,8 +217,8 @@ class TestV54TrashPurge:
with get_conn() as conn: with get_conn() as conn:
# insert 2 deleted pages: one old, one recent # insert 2 deleted pages: one old, one recent
old = datetime.datetime.utcnow() - datetime.timedelta(days=45) old = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=45)
recent = datetime.datetime.utcnow() - datetime.timedelta(days=5) recent = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=5)
cur = conn.execute( cur = conn.execute(
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)", "INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
("test", "Old Page", old.isoformat()), ("test", "Old Page", old.isoformat()),