fix: A30 + A37 + A39 + A40 + A41 — fin du P2/P3 XS/S (v7.4.0)
- 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
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## 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
|
||||
|
||||
+6
-6
@@ -1150,21 +1150,21 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
- [ ] **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**.*
|
||||
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
|
||||
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
|
||||
- [ ] **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**.*
|
||||
- [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**.*
|
||||
- [ ] **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 — 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**.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.4.0 (audit — A30/A37/A39/A40/A41) | **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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+17
-2
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.3.9",
|
||||
version="7.4.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)
|
||||
|
||||
+1
-18
@@ -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."""
|
||||
|
||||
+71
-208
@@ -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,6 +24,7 @@ from app.services.api_v2_helpers import (
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
@@ -47,10 +48,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 +109,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 +124,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 +166,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 +232,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 +282,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 +310,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 +336,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 +374,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 +400,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 +451,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 +507,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 +532,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 +545,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 +582,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 +607,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 +631,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 +701,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 +781,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 +828,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 +845,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 +862,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 +891,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 +920,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 +945,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 +967,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 +992,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 +1016,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 +1028,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 +1101,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 +1126,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 +1157,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 +1169,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 +1196,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 +1212,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 +1234,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 +1255,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 +1278,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 +1304,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 +1318,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 +1419,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 +1442,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 +1467,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 +1488,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 +1506,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 +1515,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 +1534,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 +1566,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 +1593,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 +1611,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,10 +1627,8 @@ 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:
|
||||
@@ -1749,10 +1648,8 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
|
||||
|
||||
@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()
|
||||
@@ -1776,10 +1673,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 +1715,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 +1738,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 +1763,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 +1772,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 +1791,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 +1836,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 +1853,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 +1872,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 +1881,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 +1904,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 +1979,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 +2156,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 +2184,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 +2205,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 +2215,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 +2264,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
|
||||
|
||||
|
||||
@@ -850,15 +850,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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.3.9"
|
||||
"version": "7.4.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,6 +65,35 @@ 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_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
Reference in New Issue
Block a user