Compare commits

...
4 Commits
Author SHA1 Message Date
bruno f706424f90 fix: A31 — transaction par migration + helper columns() (v7.6.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` →
  commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit
  (isolation_level legacy) : un échec au milieu laissait un schéma partiel
  commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué.
  Si une transaction englobante subsiste (init_db commit juste avant), on la
  vide d'abord plutôt que de l'englober.
- Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon)
  : 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`
  éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row).
  `table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune
  migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set.
- Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent.

tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque
de version), test_columns_helper_validates_table_name

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

tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired

suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 10:01:39 -04:00
bruno 937ecfc2e0 fix: A30 + A37 + A39 + A40 + A41 — fin du P2/P3 XS/S (v7.4.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- A30 — `require_scope()` câblé : 69 sites stricts de api_v2.py passent par la
  factory (Bearer + scope en 1 appel, contrôle manuel supprimé) ; sémantique
  alignée sur celle des handlers (pas de default "read" → 0 changement de
  comportement) ; 12 top-level morts supprimés (0 ref app ET tests) :
  unsync_block, find_referring, _b64url, strip_markdown, format_number,
  get_auto_property_value, get_next_unique_id, local_date_in_tz,
  verify_device_token, _get_dynamic_groups, _require_user_gitea,
  validate_upload_request
- A37 — CORS sans `*` : origines = app_base_url + allow_origin_regex
  (localhost/dev, origines d'extension pour le Web Clipper), méthodes et
  entêtes minutées, allow_credentials explicite + test test_cors_no_star
- A39 — htmx : décision « rien » documentée (32 attributs hx-* réels sur 6
  templates, conversion = refonte du view-switching sans test E2E)
- A40 — version d'assets à source unique : ENV.globals["asset_version"] lu au
  boot depuis le fichier VERSION ; littéraux `?v=` de base.html éliminés ;
  test test_asset_version_single_source
- A41 — app.css : 91 règles mortes purgées (-10 274 octets, 121 618 → 111 344),
  scan templates/JS/CSS/Python à 0 référence

suite **1031/1031** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.4.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 09:30:37 -04:00
bruno 998b5c630c docs(roadmap): A43 marque partiel — placeholder CSRF et palette restent ouverts
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Les deux sous-items JS de A43 ne sont pas traits (utcnow et health log le sont).
2026-10-01 08:51:12 -04:00
34 changed files with 568 additions and 970 deletions
+79
View File
@@ -1,5 +1,84 @@
# Changelog - FlowDeck
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
### Fixed
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
laissait un schéma partiel commité SANS ligne de version, et la reprise
rejouait un DDL déjà appliqué
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
`ValueError` sinon) : **25 copies** de
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
se lit dans le set
### Tests
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
## 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
### Changed
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
dérivées de `settings.app_base_url` + localhost/origines d'extension
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
explicite ; test `test_cors_no_star`
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
templates, JS, autres CSS et code Python
### Notes
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
automatisé du view-switch
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
+10 -10
View File
@@ -1149,24 +1149,24 @@ 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**.*
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
- [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] **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.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. 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**.*
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
- [x] **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
- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
- [ ] **A39 — HTMX chargé (50 Ko) pour 10 attributs** vs 265 `fetch(` manuels (36 `hx-*` dont 10 réels ; `settings.html` 42 fetch/0 hx, `_page_editor_scripts` 56/0). *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien. Effort : **XS**.*
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
- [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**.*
- [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] **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] **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**.*
### ✅ 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.
*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.**
→ **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. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0.
+1 -1
View File
@@ -1 +1 @@
7.3.9
7.6.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.6.0 (audit — A31) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
-2
View File
@@ -44,8 +44,6 @@ def pkce_pair() -> tuple[str, str]:
return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
+10
View File
@@ -1,12 +1,22 @@
"""FlowDeck — Configuration via pydantic-settings."""
from __future__ import annotations
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
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(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
+17 -2
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.3.9",
version="7.6.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -195,7 +195,22 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
app.add_middleware(CSRFMiddleware)
app.add_middleware(ContentSecurityPolicyMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# A37 : origines explicites (l'auth est un cookie de session ; le front est
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
allow_credentials=True,
)
app.include_router(auth.router)
app.include_router(sso_router)
+61 -28
View File
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
)
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
unitaire se lit déjà dans le set.
"""
if not table.replace("_", "").isalnum():
raise ValueError(f"nom de table invalide: {table!r}")
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
_apply_one(conn, version, name, fn)
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
"""A31 : une migration = une transaction (DDL tout-ou-rien).
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
rollback complet à l'échec, donc la prochaine exécution retente proprement.
"""
if conn.in_transaction:
# transaction résiduelle du caller (init_db commit juste avant) — on
# part d'un état propre plutôt que d'englober son travail.
conn.commit()
conn.execute("BEGIN")
try:
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
except BaseException:
conn.rollback()
raise
# ═══════════════════════════════════════════════════════════════════════════
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_pcols = columns(conn, "api_tokens")
if "id" not in _pcols:
conn.execute(
"""
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
_scols = columns(conn, "user_sessions")
if "id" not in _scols:
conn.execute(
"""
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
_projcols = columns(conn, "projects")
if "id" not in _projcols:
conn.execute(
"""
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
_cols = columns(conn, "database_templates")
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
_vcols = columns(conn, "collection_views")
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
_cpcols = columns(conn, "collection_pages")
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
_ucols = columns(conn, "users")
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
)
for table in ("pages", "collection_pages"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "permission_type" not in cols:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
_ccols = columns(conn, "collections")
if "permission_type" not in _ccols:
conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
``idempotency_keys`` — Idempotency-Key support for POST creations.
"""
# api_tokens extra columns
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_cols = columns(conn, "api_tokens")
if "scopes" not in _cols:
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
if "expires_at" not in _cols:
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
New statuses: ``retrying`` (a later attempt is scheduled) and
``superseded`` (a retry row replaced this attempt).
"""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
_cols = columns(conn, "webhook_deliveries")
if "event" not in _cols:
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
if "next_retry_at" not in _cols:
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``).
"""
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "collection_row_id" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
cols = columns(conn, "collections")
if "form_config_json" not in cols:
conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
)
"""
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "search_excluded" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
"ON worker_runs(worker_id, created_at)"
)
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
auto_cols = columns(conn, "automations")
if "trigger_mode" not in auto_cols:
conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
)
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
prop_cols = columns(conn, "collection_properties")
if "button_automation_id" not in prop_cols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
"ON meeting_transcripts(page_id)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
cols = columns(conn, "collection_pages")
if "external_event_id" not in cols:
conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
"ON agent_approvals(status, created_at)"
)
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
user_cols = columns(conn, "users")
if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols:
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
"""
)
for table in ("pages", "collections"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
+1 -18
View File
@@ -1,10 +1,9 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
from app.middleware.security import ALLOWED_EXTENSIONS, _ext
# ── File Save ────────────────────────────────────────────────
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
error: str | None = None
def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue."""
+6 -1
View File
@@ -551,8 +551,13 @@ async def get_my_profile(request: Request):
if not user:
return {"login": "guest", "full_name": "Guest", "email": ""}
with get_conn() as conn:
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
# password_hash, login_attempts et locked_until.
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()
if row:
return dict(row)
+77 -225
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app.db import get_conn
from app.services.api_v2_helpers import (
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
@@ -24,12 +24,14 @@ from app.services.api_v2_helpers import (
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
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__)
router = APIRouter(prefix="/api/v2", tags=["api-v2"])
@@ -47,10 +49,8 @@ def _v2_rate_check(request: Request, user: dict) -> None:
@router.post("/tokens")
async def create_token(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -110,10 +110,8 @@ async def list_tokens(request: Request, authorization: str | None = Header(defau
@router.delete("/tokens/{token_id}")
async def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
if not row:
@@ -127,10 +125,8 @@ async def revoke_token(token_id: int, request: Request, authorization: str | Non
@router.post("/tokens/{token_id}/rotate")
async def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
if not row:
@@ -171,10 +167,8 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
@router.patch("/users/me")
async def patch_me(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -239,10 +233,8 @@ async def list_workspaces(request: Request, authorization: str | None = Header(d
@router.post("/workspaces")
async def create_workspace(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
@@ -291,10 +283,8 @@ async def get_workspace(workspace_id: int, request: Request, authorization: str
@router.patch("/workspaces/{workspace_id}")
async def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -321,10 +311,8 @@ async def patch_workspace(workspace_id: int, request: Request, authorization: st
@router.delete("/workspaces/{workspace_id}")
async def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
@@ -349,10 +337,8 @@ async def list_workspace_members(workspace_id: int, request: Request, authorizat
@router.post("/workspaces/{workspace_id}/members")
async def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -389,10 +375,8 @@ async def invite_member(workspace_id: int, request: Request, authorization: str
@router.patch("/workspaces/{workspace_id}/members/{uid}")
async def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -417,10 +401,8 @@ async def update_member_role(workspace_id: int, uid: int, request: Request, auth
@router.delete("/workspaces/{workspace_id}/members/{uid}")
async def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not ws:
@@ -470,10 +452,8 @@ async def list_collections_v2(request: Request, authorization: str | None = Head
@router.post("/collections")
async def create_collection_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
@@ -528,10 +508,8 @@ async def get_collection_v2(collection_id: int, request: Request, authorization:
@router.patch("/collections/{collection_id}")
async def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -555,10 +533,8 @@ async def patch_collection_v2(collection_id: int, request: Request, authorizatio
@router.delete("/collections/{collection_id}")
async def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
@@ -570,10 +546,8 @@ async def delete_collection_v2(collection_id: int, request: Request, authorizati
@router.post("/collections/{collection_id}/linked")
async def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -609,10 +583,8 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
@router.post("/collections/{collection_id}/task")
async def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
@@ -636,10 +608,8 @@ async def list_sources(collection_id: int, request: Request, authorization: str
@router.post("/collections/{collection_id}/sources")
async def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -662,10 +632,8 @@ async def add_source(collection_id: int, request: Request, authorization: str |
@router.delete("/collections/{collection_id}/sources/{source_id}")
async def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id))
conn.commit()
@@ -734,10 +702,8 @@ async def list_collection_pages_v2(collection_id: int, request: Request, authori
@router.post("/collections/{collection_id}/pages")
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
@@ -816,10 +782,8 @@ async def get_page_v2(page_id: int, request: Request, authorization: str | None
@router.patch("/pages/{page_id}")
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -865,10 +829,8 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
@router.delete("/pages/{page_id}")
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -884,10 +846,8 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
@router.post("/pages/{page_id}/restore")
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone()
@@ -903,10 +863,8 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
@router.post("/pages/{page_id}/move")
async def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -934,10 +892,8 @@ async def list_sub_items_v2(page_id: int, request: Request, authorization: str |
@router.post("/pages/{page_id}/sub-items")
async def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -965,10 +921,8 @@ async def list_dependencies_v2(page_id: int, request: Request, authorization: st
@router.post("/pages/{page_id}/dependencies")
async def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -992,10 +946,8 @@ async def add_dependency_v2(page_id: int, request: Request, authorization: str |
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
async def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
conn.commit()
@@ -1016,10 +968,8 @@ async def list_properties_v2(collection_id: int, request: Request, authorization
@router.post("/collections/{collection_id}/properties")
async def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1043,10 +993,8 @@ async def create_property_v2(collection_id: int, request: Request, authorization
@router.patch("/properties/{prop_id}")
async def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1069,10 +1017,8 @@ async def patch_property_v2(prop_id: int, request: Request, authorization: str |
@router.delete("/properties/{prop_id}")
async def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone():
raise HTTPException(404, "Property not found")
@@ -1083,10 +1029,8 @@ async def delete_property_v2(prop_id: int, request: Request, authorization: str
@router.post("/properties/{prop_id}/relation")
async def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1158,10 +1102,8 @@ async def list_views_v2(collection_id: int, request: Request, authorization: str
@router.post("/collections/{collection_id}/views")
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1185,10 +1127,8 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
@router.patch("/views/{view_id}")
async def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1218,10 +1158,8 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
@router.delete("/views/{view_id}")
async def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
raise HTTPException(404, "View not found")
@@ -1232,10 +1170,8 @@ async def delete_view_v2(view_id: int, request: Request, authorization: str | No
@router.post("/views/{view_id}/save-as")
async def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1261,10 +1197,8 @@ async def list_dashboards_v2(collection_id: int, request: Request, authorization
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1279,10 +1213,8 @@ async def create_dashboard_v2(collection_id: int, request: Request, authorizatio
@router.patch("/dashboards/{dashboard_id}")
async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1303,10 +1235,8 @@ async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization:
@router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
conn.commit()
@@ -1326,10 +1256,8 @@ async def list_comments_v2(page_id: int, request: Request, authorization: str |
@router.post("/pages/{page_id}/comments")
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1351,10 +1279,8 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
@router.patch("/comments/{comment_id}")
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1379,10 +1305,8 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
@router.delete("/comments/{comment_id}")
async def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
@@ -1395,10 +1319,8 @@ async def delete_comment_v2(comment_id: int, request: Request, authorization: st
@router.post("/pages/{page_id}/mentions")
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1498,10 +1420,8 @@ async def list_favorites_v2(request: Request, authorization: str | None = Header
@router.post("/favorites")
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1523,10 +1443,8 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
@router.delete("/favorites/{page_id}")
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
conn.commit()
@@ -1550,10 +1468,8 @@ async def list_tags_v2(request: Request, authorization: str | None = Header(defa
@router.post("/tags")
async def create_tag_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1573,10 +1489,8 @@ async def create_tag_v2(request: Request, authorization: str | None = Header(def
@router.patch("/tags/{tag_id}")
async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1593,10 +1507,8 @@ async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None
@router.delete("/tags/{tag_id}")
async def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
conn.commit()
@@ -1604,10 +1516,8 @@ async def delete_tag_v2(tag_id: int, request: Request, authorization: str | None
@router.post("/pages/{page_id}/tags")
async def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1625,10 +1535,8 @@ async def attach_tag_v2(page_id: int, request: Request, authorization: str | Non
@router.delete("/pages/{page_id}/tags/{tag_id}")
async def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
conn.commit()
@@ -1659,10 +1567,8 @@ async def list_shares_v2(page_id: int, request: Request, authorization: str | No
@router.post("/pages/{page_id}/shares")
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1688,10 +1594,8 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
@router.patch("/shares/{share_id}")
async def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1708,10 +1612,8 @@ async def patch_share_v2(share_id: int, request: Request, authorization: str | N
@router.delete("/shares/{share_id}")
async def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
if not row:
@@ -1726,40 +1628,24 @@ async def delete_share_v2(share_id: int, request: Request, authorization: str |
@router.post("/pages/{page_id}/publish")
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
body = {}
slug = (body.get("slug") or body.get("publish_slug") or f"p-{page_id}-{secrets.token_urlsafe(6)}").strip()
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=1, publish_slug=?, is_shared=1 WHERE id=?", (slug, page_id))
conn.commit()
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
slug, _title = publish(page_id, explicit_slug=slug_in)
audit_log(user, "page.publish", "page", page_id, slug, request)
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page_v2")
await fire_published(page_id, slug)
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish")
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("UPDATE pages SET is_published=0 WHERE id=?", (page_id,))
conn.commit()
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page_v2")
unpublish(page_id)
await fire_unpublished(page_id)
return {"page_id": page_id, "status": "unpublished"}
# ── History ───────────────────────────────────────────────────────────────
@@ -1776,10 +1662,8 @@ async def list_history_v2(page_id: int, request: Request, authorization: str | N
@router.post("/pages/{page_id}/history/restore")
async def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1820,10 +1704,8 @@ async def list_sprints_v2(collection_id: int, request: Request, authorization: s
@router.post("/collections/{collection_id}/sprints")
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1845,10 +1727,8 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
@router.patch("/sprints/{sprint_id}")
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1872,10 +1752,8 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
@router.delete("/sprints/{sprint_id}")
async def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
conn.commit()
@@ -1883,10 +1761,8 @@ async def delete_sprint_v2(sprint_id: int, request: Request, authorization: str
@router.post("/sprints/{sprint_id}/assign")
async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1904,10 +1780,8 @@ async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
async def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
conn.commit()
@@ -1951,10 +1825,8 @@ async def list_templates_v2(collection_id: int, request: Request, authorization:
@router.post("/collections/{collection_id}/templates")
async def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1970,10 +1842,8 @@ async def create_template_v2(collection_id: int, request: Request, authorization
@router.patch("/templates/{template_id}")
async def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1991,10 +1861,8 @@ async def patch_template_v2(template_id: int, request: Request, authorization: s
@router.delete("/templates/{template_id}")
async def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
conn.commit()
@@ -2002,10 +1870,8 @@ async def delete_template_v2(template_id: int, request: Request, authorization:
@router.post("/templates/{template_id}/apply")
async def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
@@ -2027,10 +1893,8 @@ async def list_db_templates_v2(request: Request, authorization: str | None = Hea
@router.post("/templates/database/{template_id}/apply")
async def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -2104,10 +1968,8 @@ async def export_csv_v2(collection_id: int, request: Request, authorization: str
@router.post("/collections/{collection_id}/import/csv")
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
form = await request.form()
file = form.get("file")
@@ -2283,10 +2145,8 @@ async def list_webhooks_v2(request: Request, authorization: str | None = Header(
@router.post("/webhooks")
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -2313,10 +2173,8 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
@router.patch("/webhooks/{webhook_id}")
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -2336,10 +2194,8 @@ async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str
@router.delete("/webhooks/{webhook_id}")
async def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
conn.commit()
@@ -2348,10 +2204,8 @@ async def delete_webhook_v2(webhook_id: int, request: Request, authorization: st
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
@@ -2399,10 +2253,8 @@ async def list_deliveries_v2(webhook_id: int, request: Request,
async def retry_webhook_deliveries(webhook_id: int, request: Request,
authorization: str | None = Header(default=None)):
"""Manually retry failed deliveries for a webhook."""
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
from app.services.webhook_outbound import retry_due_deliveries
+12 -35
View File
@@ -10,12 +10,14 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -850,15 +852,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
return [dict(r) for r in rows]
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
"""Return groups from Gitea labels/milestones or fallback to defaults."""
try:
labels = json.loads(
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
) if False else []
except Exception:
labels = []
return labels if labels else ["Design", "Engineering", "No Team"]
async def asyncio_get_labels(owner: str, repo: str):
@@ -1020,35 +1013,20 @@ async def update_share(request: Request, page_id: int):
@router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
import secrets
slug = "p-" + secrets.token_urlsafe(8)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
(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 ""}
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
slug, title = publish(page_id)
await fire_published(page_id, slug)
return {"is_published": True, "publish_slug": slug, "title": title}
@router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
with get_conn() as conn:
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")
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
unpublish(page_id)
await fire_unpublished(page_id)
return {"is_published": False}
@@ -1695,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _ws_id_for(request: Request, page_id: int) -> int:
+9 -9
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
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, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
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]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@@ -1143,9 +1147,8 @@ def _file_page_disk_path(page: dict):
parts = rel.split("/")
if ".." in parts or "." in parts:
return None
import os as _os
from pathlib import Path
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
root = Path(settings.data_dir).resolve()
full = (root / rel).resolve()
try:
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):
"""Serve an uploaded file from disk."""
import mimetypes
import os
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()
fp = (base_dir / filename).resolve()
try:
@@ -1568,11 +1570,10 @@ async def upload_local_workspace_file(request: Request):
if not files:
return JSONResponse({"error": "No files provided"}, status_code=400)
import os
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.mkdir(parents=True, exist_ok=True)
@@ -1656,11 +1657,10 @@ async def upload_local_workspace_folder(request: Request):
except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
import os
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.mkdir(parents=True, exist_ok=True)
+2 -2
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn
router = APIRouter(tags=["emojis"])
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
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:
-8
View File
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
return client
# ── Orgs ──
@router.get("/orgs")
async def list_orgs(request: Request):
"""List organizations the user belongs to."""
+5 -58
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
@@ -11,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
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__)
router = APIRouter(tags=["sharing"], prefix="/api")
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
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 ──
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
_require_auth(request)
with get_conn() as conn:
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")
slug, _title = publish(page_id)
await fire_published(page_id, slug)
return {
"page_id": page_id,
"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):
"""Unpublish a page."""
_require_auth(request)
with get_conn() as conn:
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")
unpublish(page_id)
await fire_unpublished(page_id)
return {
"page_id": page_id,
"is_published": False,
+5 -1
View File
@@ -214,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
return user
def require_scope(required: str):
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization)
scopes = user.get("_token_scopes") or "read"
# Pas de default "read" : identique au contrôle manuel des handlers
# (un jeton sans scope est refusé, quel que soit le scope demandé).
scopes = user.get("_token_scopes")
if not has_scope(scopes, required):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
return user
+2 -2
View File
@@ -17,12 +17,12 @@ from __future__ import annotations
import io
import json
import os
import re
import zipfile
from pathlib import Path
from urllib.parse import quote
from app.config import settings
from app.db import get_conn
# ═══════════════ Helpers ═══════════════
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
def _data_root() -> Path:
"""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:
+4 -1
View File
@@ -35,7 +35,10 @@ class GiteaClient:
return 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 ──
-6
View File
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
+2 -2
View File
@@ -10,11 +10,11 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from app.config import settings
from app.db import get_conn
from app.services.db_templates import materialize_properties
from app.services.export import markdown_to_blocks
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _data_dir() -> Path:
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _safe_filename(name: str) -> str:
+2 -1
View File
@@ -16,6 +16,7 @@ import shutil
import subprocess
from pathlib import Path
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ class TranscriptionUnavailable(RuntimeError):
def meetings_dir() -> Path:
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
root = Path(settings.data_dir)
d = root / "uploads" / "meetings"
d.mkdir(parents=True, exist_ok=True)
return d
-30
View File
@@ -247,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
return user_ref(user)
return None
def apply_auto_properties(
@@ -291,28 +284,5 @@ def apply_auto_properties(
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
+91
View File
@@ -0,0 +1,91 @@
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
Les trois surfaces divergeaient avant cette passe :
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
"""
from __future__ import annotations
import logging
import re
import secrets
import unicodedata
from fastapi import HTTPException
from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
def slugify(title: str) -> str:
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug
def _unique_slug(conn, page_id: int, title: str) -> str:
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
slug, counter = base, 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base}-{counter}"
counter += 1
return slug
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
with get_conn() as conn:
page = conn.execute(
"SELECT id, title FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
def unpublish(page_id: int) -> None:
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
async def fire_published(page_id: int, slug: str) -> None:
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish page.published")
async def fire_unpublished(page_id: int) -> None:
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("publish page.unpublished")
-3
View File
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
-11
View File
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
-13
View File
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
def verify_device_token(device_id: str, token: str) -> dict | None:
"""Verify a device token, returns device row or None."""
thash = _hash_token(token)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
(device_id, thash),
).fetchone()
if row:
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
return dict(row)
return None
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
-5
View File
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
+5 -5
View File
@@ -10,9 +10,9 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -2129,7 +2129,7 @@
}
});
</script>
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2377,7 +2377,7 @@
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
+14
View File
@@ -6,9 +6,23 @@ les `|safe` du codebase étaient des no-op.
"""
from __future__ import annotations
import pathlib
from jinja2 import Environment, FileSystemLoader, select_autoescape
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
)
# A40 : version des assets statiques première main, lue une seule fois (le
# fichier VERSION fait foi ; repli "dev" si l'image ne le contient pas).
try:
ASSET_VERSION = (pathlib.Path(__file__).resolve().parent.parent / "VERSION").read_text(
encoding="utf-8"
).strip()
except OSError: # pragma: no cover
ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION
+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.
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version` = VERSION courante).
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`.
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.3.9"
"version": "7.6.0"
},
"paths": {
"/auth/register": {
-488
View File
@@ -390,28 +390,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.um-version { font-size: 11px; color: var(--text-dim); padding: 4px 14px 2px; }
.um-section-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; padding: 4px 14px 2px; }
.sidebar-actions {
display: flex;
align-items: center;
gap: 2px;
}
.sidebar-action-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 14px;
transition: background var(--transition);
}
.sidebar-action-btn:hover {
background: var(--bg-hover);
}
/* Navigation items */
.sidebar-nav {
display: flex;
@@ -421,27 +399,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
margin-bottom: var(--space-xs);
}
.sidebar-nav-item {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 8px;
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
transition: background var(--transition);
white-space: nowrap;
}
.sidebar-nav-item:hover {
background: var(--bg-hover);
}
.sidebar-nav-item.active {
background: var(--bg-active);
}
.sidebar-nav-item .nav-icon {
font-size: 16px;
width: 20px;
@@ -877,7 +834,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
}
.nav-icon-inline { margin-right: 6px; vertical-align: middle; display: inline-flex; align-items: center; }
.nav-icon-inline svg { vertical-align: middle; }
.empty-state-icon { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; }
.sidebar-item:active .page-icon {
cursor: grabbing;
@@ -967,60 +923,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
padding: var(--space-sm) var(--space-md);
}
.sidebar-invite {
background: var(--bg-hover);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-sm);
position: relative;
}
.sidebar-invite-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.sidebar-invite-desc {
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-invite-close {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 12px;
}
.sidebar-new-chat {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px var(--space-md);
background: var(--bg-hover);
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
width: 100%;
transition: background var(--transition);
}
.sidebar-new-chat:hover {
background: var(--bg-tertiary);
}
.sidebar-new-chat .shortcut {
font-size: 12px;
color: var(--text-secondary);
@@ -1137,35 +1039,10 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
flex-shrink: 0;
}
.topbar-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
min-width: 0;
}
.topbar-page-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 14px;
color: var(--text-primary);
}
.topbar-page-title .page-icon {
font-size: 16px;
}
.topbar-page-status {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.topbar-right {
display: flex;
align-items: center;
@@ -1201,14 +1078,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
background: var(--bg-hover);
}
.topbar-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--text-secondary);
}
/* ── Unified Header Breadcrumb ── */
.header-breadcrumb {
display: flex;
@@ -1406,16 +1275,6 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-hover);
}
.option-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.topbar-breadcrumb a:hover {
color: var(--text-primary);
}
/* Content area */
.content-area {
flex: 1;
@@ -1424,11 +1283,6 @@ button.breadcrumb-current { max-width: 260px; }
padding: 0;
}
/* Page header */
.page-header {
padding: var(--space-xl) var(--space-xl) var(--space-md);
}
.page-cover {
width: 100%;
height: 160px;
@@ -1755,22 +1609,6 @@ button.breadcrumb-current { max-width: 260px; }
font-weight: 500;
}
/* Card colors by status */
.card-status-todo {
background: var(--bg-secondary);
border-left: 3px solid var(--gray);
}
.card-status-progress {
background: var(--blue-bg);
border-left: 3px solid var(--blue);
}
.card-status-complete {
background: var(--green-bg);
border-left: 3px solid var(--green);
}
/* New card button */
.kanban-new-card {
display: flex;
@@ -2035,31 +1873,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--red);
}
/* Panel dropdown helpers */
.dropdown-search {
padding: var(--space-sm);
}
.dropdown-search input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px var(--space-sm);
font-size: 13px;
color: var(--text-primary);
outline: none;
}
.dropdown-search input:focus {
border-color: var(--blue);
}
.dropdown-option-list {
max-height: 240px;
overflow-y: auto;
}
.dropdown-option {
display: flex;
align-items: center;
@@ -2102,26 +1915,6 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%;
}
.dropdown-footer {
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--border);
}
.dropdown-footer button {
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.dropdown-footer button:hover {
color: var(--text-primary);
}
/* Sort panel */
.sort-panel {
position: relative;
}
.sort-list {
padding: var(--space-xs) 0;
}
@@ -2228,32 +2021,14 @@ button.breadcrumb-current { max-width: 260px; }
UTILITIES
═══════════════════════════════════════════════════════════ */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.text-dim { color: var(--text-secondary); }
.text-xs { font-size: 12px; }
.text-sm { font-size: 13px; }
.font-semibold { font-weight: 600; }
/* Library table */
.library-table-header {
display: flex; padding: 8px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; font-weight: 600; color: var(--text-dim);
}
.library-table-row {
display: flex; align-items: center; padding: 8px 12px; text-decoration: none;
color: var(--text-primary); border-radius: 6px; transition: background 0.15s;
}
.library-table-row:hover { background: var(--bg-hover); }
.lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; }
.lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); }
.lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-visited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-empty { text-align: center; padding: 48px; color: var(--text-dim); }
.lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); }
.lib-empty p { font-size: 13px; }
@@ -2282,8 +2057,6 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px; color: var(--text-secondary); transition: background 0.15s;
}
.trash-item-btn:hover { background: var(--bg-tertiary); }
.ml-auto { margin-left: auto; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
@@ -2362,11 +2135,6 @@ button.breadcrumb-current { max-width: 260px; }
text-align: center;
}
.blocks-empty-hint {
color: var(--text-dim);
font-size: 15px;
}
/* Block wrapper */
.block-wrapper {
position: relative;
@@ -2502,7 +2270,6 @@ button.breadcrumb-current { max-width: 260px; }
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
.ftable-hdr-toggle input { cursor:pointer; }
/* Block actions (+ button) */
.block-actions {
position: absolute;
@@ -2534,23 +2301,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-primary);
}
.block-drag-btn {
width: 22px;
height: 22px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 11px;
cursor: grab;
transition: background 0.1s;
}
.block-drag-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Block content (base) */
.block-content {
flex: 1;
@@ -2570,36 +2320,6 @@ button.breadcrumb-current { max-width: 260px; }
font-style: normal;
}
/* Headings */
.block-h1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
padding: 8px 2px 4px;
}
.block-h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
padding: 6px 2px 3px;
}
.block-h3 {
font-size: 20px;
font-weight: 600;
line-height: 1.3;
padding: 4px 2px 2px;
}
.block-h4 {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
padding: 4px 2px 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
/* Bulleted list */
.block-bullet {
padding-left: 18px;
@@ -2685,13 +2405,6 @@ button.breadcrumb-current { max-width: 260px; }
word-break: break-word;
}
/* Quote */
.block-quote {
border-left: 3px solid var(--text-primary);
padding: 4px 14px;
margin: 4px 0;
}
/* Code block */
.block-code {
background: var(--bg-secondary);
@@ -2835,11 +2548,6 @@ button.breadcrumb-current { max-width: 260px; }
font-style: italic;
padding: 12px 16px;
}
/* Math block */
.block-math {
margin: 8px 0;
}
.math-display {
min-height: 40px;
padding: 10px 14px;
@@ -2953,28 +2661,6 @@ button.breadcrumb-current { max-width: 260px; }
display: none;
}
/* Image block */
.block-image-wrapper {
margin: 8px 0;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: var(--bg-secondary);
border: 1px dashed var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s;
}
.image-placeholder:hover {
background: var(--bg-hover);
}
/* SLASH COMMAND MENU */
.slash-menu {
position: fixed;
@@ -2990,45 +2676,6 @@ button.breadcrumb-current { max-width: 260px; }
flex-direction: column;
}
.slash-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.slash-search input {
width: 100%;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.slash-search input:focus {
border-color: var(--blue);
}
.slash-groups {
overflow-y: auto;
padding: 4px 0;
}
.slash-group {
padding: 0;
}
.slash-group + .slash-group {
border-top: 1px solid var(--border);
}
.slash-group-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slash-item {
display: flex;
align-items: center;
@@ -3058,27 +2705,10 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 4px;
flex-shrink: 0;
}
.slash-item-info {
flex: 1;
display: flex;
flex-direction: column;
}
.slash-item-name {
font-weight: 500;
font-size: 14px;
}
.slash-item-desc {
font-size: 12px;
color: var(--text-dim);
}
.slash-item-shortcut {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
flex-shrink: 0;
}
/* FORMAT TOOLBAR (FLOATING) */
.format-toolbar {
@@ -3110,13 +2740,6 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-hover);
}
.format-separator {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
/* EDITOR STATUSBAR */
.editor-statusbar {
position: fixed;
@@ -3266,15 +2889,6 @@ button.breadcrumb-current { max-width: 260px; }
.fm-manual { margin-top: 8px; }
.fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
.fm-transcript { max-height: 40vh; overflow-y: auto; }
/* ═══════════ Page Editor — Top Bar ═══════════ */
.page-topbar {
display: flex; align-items: center; justify-content: flex-end;
padding: 6px 0; min-height: 36px;
}
.page-topbar-right {
display: flex; align-items: center; gap: 4px; position: relative;
}
.topbar-edited {
font-size: 12px; color: var(--text-dim); margin-right: 8px;
}
@@ -3435,12 +3049,6 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: var(--radius-lg); box-shadow: var(--shadow-popover);
z-index: 999; padding: 4px 0;
}
.sd-access-option {
padding: 8px 14px; font-size: 13px; color: var(--text-primary);
cursor: pointer; display: flex; align-items: center; gap: 8px;
}
.sd-access-option:hover { background: var(--bg-hover); }
.sd-access-option.active { background: rgba(35,131,226,.15); }
/* Permissions */
.sd-select-sm {
@@ -3602,22 +3210,6 @@ button.breadcrumb-current { max-width: 260px; }
.toggle-switch.sm .toggle-slider:before { height: 14px; width: 14px; }
.toggle-switch.sm input:checked + .toggle-slider:before { transform: translateX(14px); }
/* ═══════════ Empty Page Toolbar ═══════════ */
.empty-page-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 12px 0; flex-wrap: wrap;
}
.empty-toolbar-label {
font-size: 13px; color: var(--text-dim); margin-right: 6px;
}
.empty-toolbar-item {
padding: 6px 12px; background: var(--bg-secondary);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text-primary); font-size: 13px; cursor: pointer;
white-space: nowrap;
}
.empty-toolbar-item:hover { background: var(--bg-hover); border-color: var(--text-dim); }
/* ═══════════════════════════════════════════════════════════
RESPONSIVE — MOBILE-FIRST DESIGN SYSTEM
Breakpoints: ≤480px phone | ≤768px tablet | ≤1024px small desktop
@@ -3654,20 +3246,6 @@ button.breadcrumb-current { max-width: 260px; }
opacity: 1; pointer-events: auto;
}
/* ── Sidebar Back Button (mobile) ── */
.sidebar-back-btn {
display: none;
width: 36px; height: 36px;
border-radius: var(--radius-sm);
align-items: center; justify-content: center;
color: var(--text-primary); font-size: 18px;
cursor: pointer; transition: background var(--transition);
}
.sidebar-back-btn:hover { background: var(--bg-hover); }
/* ── Topbar mobile spacer ── */
.topbar-mobile-spacer { flex: 1; min-width: 0; }
/* ═══════════════════════════════════════════════════════════
TABLET (≤1024px)
═══════════════════════════════════════════════════════════ */
@@ -3764,7 +3342,6 @@ button.breadcrumb-current { max-width: 260px; }
.topbar-right span.text-xs { display: none; }
.topbar-breadcrumb { font-size: 13px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Content ── */
.content-area { overflow-y: auto; }
.page-cover-area { padding: 24px 16px 8px; }
@@ -3909,15 +3486,6 @@ button.breadcrumb-current { max-width: 260px; }
.btn-new { height: 36px; padding: 8px 12px !important; }
}
/* ══════════ Uncollapse sidebar button ══════════ */
.sidebar-uncollapse-btn {
position: fixed; top: 60px; left: 12px; z-index: 1001;
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-secondary); cursor: pointer;
}
.sidebar-uncollapse-btn:hover { background: var(--bg-hover); color: var(--text); }
/* ═══════════════════════════════════════════════════════════
SHARED COMPONENTS (migrated from design-tokens.css)
═══════════════════════════════════════════════════════════ */
@@ -3936,22 +3504,6 @@ button.breadcrumb-current { max-width: 260px; }
50% { opacity: .5; }
}
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
pointer-events: none;
}
.skeleton-card .skeleton-line {
height: 14px;
margin-bottom: 10px;
border-radius: 4px;
}
.skeleton-card .skeleton-line:last-child { margin-bottom: 0; }
.skeleton-file {
padding: 16px;
border: 1px solid var(--border);
@@ -3961,20 +3513,6 @@ button.breadcrumb-current { max-width: 260px; }
overflow: hidden;
}
.skeleton-line {
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-table { overflow: hidden; }
.skeleton-table .skeleton-line {
height: 40px;
margin-bottom: 2px;
border-radius: 0;
}
/* Toast */
.toast-container {
position: fixed;
@@ -4005,16 +3543,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--toast-success-text);
}
.toast-error {
background: var(--toast-error-bg);
color: var(--toast-error-text);
}
.toast-info {
background: var(--toast-info-bg);
color: var(--toast-info-text);
}
@keyframes toast-slide-in {
from {
opacity: 0;
@@ -4132,21 +3660,6 @@ button.breadcrumb-current { max-width: 260px; }
box-shadow: 0 0 0 2px rgba(35,131,226,.15);
}
.settings-select {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 6px 10px;
font-size: 13px;
outline: none;
cursor: pointer;
min-width: 140px;
font-family: var(--font-sans);
}
.settings-select:focus { border-color: var(--accent); }
/* Dialog */
.dialog-overlay {
position: fixed;
@@ -4420,7 +3933,6 @@ button.breadcrumb-current { max-width: 260px; }
.db-row:hover { background: var(--bg-hover); }
.db-row-new { border-bottom: 1px solid var(--border); }
.db-row-empty { height: 32px; }
.db-row-selected { background: var(--bg-active); }
.db-td {
padding: 3px 8px;
+150
View File
@@ -65,6 +65,156 @@ def test_rate_limit_key_and_prune():
assert "old" not in mw._store and "fresh" in mw._store
def test_cors_no_star(client):
"""A37 : plus de `*` — origine refusée n'a pas d'ACAO, origine autorisée oui."""
r = client.get("/api/health", headers={"Origin": "https://evil.example"})
assert "access-control-allow-origin" not in r.headers
r2 = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
assert r2.headers.get("access-control-allow-origin") == "http://localhost:8080"
def test_asset_version_single_source():
"""A40 : une seule source de version d'assets = le fichier VERSION."""
import re as _re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
version = (root / "VERSION").read_text(encoding="utf-8").strip()
from app.templating import ASSET_VERSION, ENV
assert ASSET_VERSION == version
assert ENV.globals["asset_version"] == version
src = (root / "app/templates/base.html").read_text(encoding="utf-8")
assert "app.css?v={{ asset_version }}" in src
assert "app.js?v={{ asset_version }}" in src
# plus aucun littéral de version première main dans les templates
literals = _re.findall(
r"(?:app|design-tokens|components|offline|flowdeck)\.(?:css|js)\?v=\d", src
)
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_migration_transaction_rolls_back():
"""A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni
ligne dans schema_version → la reprise rejoue proprement."""
import sqlite3 as _sqlite3
import pytest as _pytest
from app.migrations import _apply_one, _ensure_table
conn = _sqlite3.connect(":memory:")
_ensure_table(conn)
def boom(c):
c.execute("CREATE TABLE partial_x (id INTEGER)")
raise RuntimeError("boom")
with _pytest.raises(RuntimeError, match="boom"):
_apply_one(conn, 9999, "boom", boom)
assert (
conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone()
is None
), "DDL partiel non annulé"
assert (
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0]
== 0
)
# chemin nominal : DDL + marque de version dans la même transaction
_apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)"))
assert (
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0]
== 1
)
conn.close()
def test_columns_helper_validates_table_name():
"""A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant."""
import sqlite3 as _sqlite3
from app.migrations import columns
conn = _sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)")
assert columns(conn, "t1") == {"id", "nom"}
try:
columns(conn, "t1; DROP TABLE users")
raise AssertionError("identifiant non validé")
except ValueError:
pass
conn.close()
def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app