Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f706424f90 | ||
|
|
7be96f0618 | ||
|
|
937ecfc2e0 | ||
|
|
998b5c630c | ||
|
|
cb47f5c7f4 |
+2
-2
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
|
||||
DEFAULT_LANG=fr
|
||||
|
||||
# ── Database ──
|
||||
# SQLite (default): sqlite:////data/flowdeck.db
|
||||
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
|
||||
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
|
||||
# schéma retombe silencieusement sur /data/flowdeck.db).
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
|
||||
# ── Sync ──
|
||||
|
||||
+108
@@ -1,5 +1,113 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
|
||||
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
|
||||
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
|
||||
laissait un schéma partiel commité SANS ligne de version, et la reprise
|
||||
rejouait un DDL déjà appliqué
|
||||
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
|
||||
`ValueError` sinon) : **25 copies** de
|
||||
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
|
||||
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
|
||||
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
|
||||
se lit dans le set
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
|
||||
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
|
||||
|
||||
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A29** — `services/publish.py` partagé : les 3 paires publish/unpublish
|
||||
(sharing = front, board, v2) déléguent ; 404 partout (board faisait une
|
||||
mise à jour aveugle), slugify titré unique (board : aléatoire ; v2 : slug
|
||||
fourni conservé), événements centralisés, board gagne le contrôle de session.
|
||||
Les bonus divergents disparaissent (`share_mode='anyone'` pour board,
|
||||
`is_shared=1` pour v2) : le share dialog reste l'unique propriétaire de ces
|
||||
drapeaux, dépublier ne révoque donc pas un partage manuel. Les listings
|
||||
`/users/me` ×2 et collections ×3 restent : contrats versionnés distincts
|
||||
- **A42** — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` →
|
||||
`settings.data_dir` (property : lecture à chaque accès, les tests
|
||||
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à
|
||||
chaque écriture (il ne pouvait que grandir) ; les 29 `Environment(...)`
|
||||
étaient déjà couverts par A10. **Reste** : client httpx partagé (52 créations,
|
||||
à faire avec un cache par event loop)
|
||||
|
||||
### Security
|
||||
|
||||
- **Byproduct A29** — `GET /api/users/me` (v1) et le contexte Jinja de
|
||||
`/accounts` renvoyaient `SELECT *` sur `users` : **password_hash**,
|
||||
`login_attempts` et `locked_until` exposés → colonnes whitelistées
|
||||
(identiques à la liste v2)
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_publish_service_shared_and_safe` (slug, 404, partage préservé),
|
||||
`test_users_me_no_secret_columns`, `test_gitea_cache_evicts_expired`
|
||||
|
||||
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
|
||||
|
||||
### Changed
|
||||
|
||||
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
|
||||
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
|
||||
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
|
||||
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
|
||||
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
|
||||
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
|
||||
dérivées de `settings.app_base_url` + localhost/origines d'extension
|
||||
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
|
||||
explicite ; test `test_cors_no_star`
|
||||
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
|
||||
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
|
||||
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
|
||||
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
|
||||
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
|
||||
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
|
||||
templates, JS, autres CSS et code Python
|
||||
|
||||
### Notes
|
||||
|
||||
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
|
||||
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
|
||||
automatisé du view-switch
|
||||
|
||||
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
|
||||
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
|
||||
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
|
||||
la valeur par défaut (il signe les sessions)
|
||||
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
|
||||
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
|
||||
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
|
||||
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
|
||||
(croissance mémoire bornée)
|
||||
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
|
||||
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
|
||||
scheduler passés en `warning`
|
||||
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
|
||||
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
|
||||
dupliqué retiré du ROADMAP
|
||||
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
|
||||
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
|
||||
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
|
||||
(format ISO naïf identique, zéro changement de comportement)
|
||||
|
||||
### Notes
|
||||
|
||||
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
|
||||
l'alignement à 3.13 implique un rebuild d'image à valider
|
||||
|
||||
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
+15
-19
@@ -1049,10 +1049,6 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
| Feature | Fichiers | Note |
|
||||
@@ -1150,27 +1146,27 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
|
||||
### 🟡 P2 — Moyennes
|
||||
|
||||
- [ ] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
|
||||
- [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
|
||||
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
|
||||
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
|
||||
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
|
||||
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
|
||||
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
|
||||
- [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
|
||||
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
|
||||
- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**.
|
||||
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
|
||||
- [ ] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
|
||||
- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
|
||||
- [ ] **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**.*
|
||||
- [ ] **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] **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**.*
|
||||
- [x] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
|
||||
|
||||
### 🟢 P3 — Basses / hygiène
|
||||
|
||||
- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
|
||||
- [ ] **A39 — HTMX chargé (50 Ko) pour 10 attributs** vs 265 `fetch(` manuels (36 `hx-*` dont 10 réels ; `settings.html` 42 fetch/0 hx, `_page_editor_scripts` 56/0). *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien. Effort : **XS**.*
|
||||
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
|
||||
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
|
||||
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
|
||||
- [ ] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
|
||||
- [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch.
|
||||
- [x] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
|
||||
- [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
|
||||
- [x] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé.* — **fait 2026-10-01 (partiel)** : les 29 `Environment(...)` = A10 ✓ ; les 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` → `settings.data_dir` (property, lecture à chaque accès car les tests monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque écriture. **Reste** : le client httpx partagé (52 créations — un `AsyncClient` module-level est fragile avec les event loops de tests, à faire avec un cache par loop). Effort : **M**.
|
||||
- [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **reste** : `__CSRF_PLACEHOLDER__` (base.html, fenêtre de course JS) et le re-parse JSON de la palette par frappe : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
|
||||
|
||||
### ✅ Vérifié non-problème (ne pas re-checker)
|
||||
|
||||
@@ -1185,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||
|
||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8.**
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.8 (audit sécurité — A25 + A21 partiel) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.6.0 (audit — A31) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -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:
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
@@ -31,7 +31,7 @@ class SessionManager:
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -94,7 +94,7 @@ class SessionManager:
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
|
||||
+13
-4
@@ -1,12 +1,22 @@
|
||||
"""FlowDeck — Configuration via pydantic-settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@property
|
||||
def data_dir(self) -> str:
|
||||
"""Racine des fichiers (avatars, uploads…).
|
||||
|
||||
Pas un champ : la lecture est faite à chaque accès parce que les tests
|
||||
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
|
||||
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
|
||||
"""
|
||||
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||
)
|
||||
@@ -22,9 +32,6 @@ class Settings(BaseSettings):
|
||||
github_oauth_client_id: str = ""
|
||||
github_oauth_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||
oauth_redirect_uri: str = ""
|
||||
@@ -129,7 +136,9 @@ class Settings(BaseSettings):
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z]:', p):
|
||||
return Path(p)
|
||||
return Path("/" + p)
|
||||
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
|
||||
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
|
||||
return Path("/" + p.lstrip("/"))
|
||||
return Path("/data/flowdeck.db")
|
||||
|
||||
|
||||
|
||||
+58
-11
@@ -71,6 +71,31 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _spawn(name: str, factory):
|
||||
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
|
||||
|
||||
Loggue l'exception puis recrée la coroutine 10 s plus tard.
|
||||
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
|
||||
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
|
||||
le bruit devient un problème.
|
||||
"""
|
||||
|
||||
async def _guard():
|
||||
while True:
|
||||
try:
|
||||
await factory()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
else:
|
||||
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return asyncio.create_task(_guard())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
@@ -81,6 +106,13 @@ async def lifespan(_app: FastAPI):
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
|
||||
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
|
||||
if settings.app_secret_key == "change-me-to-random":
|
||||
raise RuntimeError(
|
||||
"APP_SECRET_KEY non défini — générer une valeur : "
|
||||
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
|
||||
)
|
||||
|
||||
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
||||
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
||||
with get_conn() as conn:
|
||||
@@ -99,41 +131,41 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
|
||||
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
automation_task = _spawn("automation_scheduler", automation_scheduler)
|
||||
|
||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||
from app.services.backup import backup_scheduler
|
||||
backup_task = asyncio.create_task(backup_scheduler())
|
||||
backup_task = _spawn("backup_scheduler", backup_scheduler)
|
||||
|
||||
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
||||
from app.services.projects import project_sync_scheduler
|
||||
projects_task = asyncio.create_task(project_sync_scheduler())
|
||||
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
|
||||
|
||||
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
|
||||
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = asyncio.create_task(semantic_index_scheduler())
|
||||
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||
|
||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||
from app.services.calendar_sync import calendar_sync_scheduler
|
||||
calendar_task = asyncio.create_task(calendar_sync_scheduler())
|
||||
calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
try:
|
||||
@@ -153,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.3.8",
|
||||
version="7.6.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -163,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,6 +1,7 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Paths that should be rate-limited
|
||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||
"/api/", "/board/api/", "/auth/",
|
||||
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
||||
# API workspace + collections (les endpoints mutants du legacy).
|
||||
"/scim/v2/", "/workspace/", "/db/",
|
||||
)
|
||||
|
||||
# Pages publiques : seul le non-GET est plafonné (brute force de
|
||||
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
||||
# visiteurs d'un site publié qui partagent une IP.
|
||||
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
||||
|
||||
# Paths exempt from rate limiting even under an API prefix
|
||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||
"/api/health",
|
||||
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"/api/frontend-errors",
|
||||
})
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
||||
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||
self._last_prune = 0.0
|
||||
self._max_keys = 5000
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if not settings.rate_limit_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Only rate-limit API routes
|
||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
||||
method = request.method.upper()
|
||||
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
||||
method not in ("GET", "HEAD", "OPTIONS")
|
||||
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
||||
)
|
||||
if not limited:
|
||||
return await call_next(request)
|
||||
|
||||
# Exempt health check and error capture
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
limit = self.max_requests or settings.rate_limit_requests
|
||||
ip = self._client_key(request)
|
||||
now = time.time()
|
||||
|
||||
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
||||
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
||||
self._prune(now)
|
||||
|
||||
window_start, count = self._store[ip]
|
||||
if now - window_start > self.window_seconds:
|
||||
self._store[ip] = (now, 1)
|
||||
return await call_next(request)
|
||||
|
||||
if count >= self.max_requests:
|
||||
if count >= limit:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
self._store[ip] = (window_start, count + 1)
|
||||
return await call_next(request)
|
||||
|
||||
def _client_key(self, request: Request) -> str:
|
||||
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
||||
|
||||
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
||||
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
||||
internaute, donc le XFF du proxy fait foi.
|
||||
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
||||
exposée directement), prendre la dernière adresse non privée de la
|
||||
chaîne plutôt que la première.
|
||||
"""
|
||||
host = request.client.host if request.client else "unknown"
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
try:
|
||||
direct = ipaddress.ip_address(host)
|
||||
if direct.is_private or direct.is_loopback:
|
||||
return fwd.split(",")[0].strip() or host
|
||||
except ValueError:
|
||||
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
||||
return host
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
||||
for k in expired:
|
||||
del self._store[k]
|
||||
self._last_prune = now
|
||||
|
||||
+61
-28
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
|
||||
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
|
||||
|
||||
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
|
||||
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
|
||||
unitaire se lit déjà dans le set.
|
||||
"""
|
||||
if not table.replace("_", "").isalnum():
|
||||
raise ValueError(f"nom de table invalide: {table!r}")
|
||||
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
_apply_one(conn, version, name, fn)
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
|
||||
"""A31 : une migration = une transaction (DDL tout-ou-rien).
|
||||
|
||||
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
|
||||
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
|
||||
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
|
||||
rollback complet à l'échec, donc la prochaine exécution retente proprement.
|
||||
"""
|
||||
if conn.in_transaction:
|
||||
# transaction résiduelle du caller (init_db commit juste avant) — on
|
||||
# part d'un état propre plutôt que d'englober son travail.
|
||||
conn.commit()
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(version, name),
|
||||
)
|
||||
conn.commit()
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
``projects`` — normalized project list across forges (builtin/gitea/
|
||||
github) + last sync timestamp for the periodic cron.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_pcols = columns(conn, "api_tokens")
|
||||
if "id" not in _pcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
|
||||
)
|
||||
|
||||
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
|
||||
_scols = columns(conn, "user_sessions")
|
||||
if "id" not in _scols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
|
||||
)
|
||||
|
||||
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
_projcols = columns(conn, "projects")
|
||||
if "id" not in _projcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
|
||||
)
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "cover_url" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
if "page_icon" not in _pcols:
|
||||
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
and the built-in database templates are seeded (idempotently)."""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
|
||||
_cols = columns(conn, "database_templates")
|
||||
if "icon" not in _cols:
|
||||
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "validation_json" not in _pcols:
|
||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
||||
|
||||
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
|
||||
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||
cards), independent from the block-page ``pages.cover_url``.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "group_name" not in _pcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
|
||||
_vcols = columns(conn, "collection_views")
|
||||
if "created_by" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||
if "updated_at" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
_cpcols = columns(conn, "collection_pages")
|
||||
if "cover_url" not in _cpcols:
|
||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
|
||||
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
|
||||
)
|
||||
|
||||
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
_ucols = columns(conn, "users")
|
||||
if "timezone" not in _ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
|
||||
``page_global_templates`` — user-created global page templates
|
||||
(blocks_json = same format as the block editor saves).
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "is_locked" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||
if "locked_by" not in _pcols:
|
||||
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
_ccols = columns(conn, "collections")
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "sync_version" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_cols = columns(conn, "api_tokens")
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
_cols = columns(conn, "webhook_deliveries")
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
cols = columns(conn, "collections")
|
||||
if "form_config_json" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
|
||||
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "search_excluded" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
|
||||
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
|
||||
"ON worker_runs(worker_id, created_at)"
|
||||
)
|
||||
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
||||
auto_cols = columns(conn, "automations")
|
||||
if "trigger_mode" not in auto_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
||||
)
|
||||
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
prop_cols = columns(conn, "collection_properties")
|
||||
if "button_automation_id" not in prop_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
|
||||
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
|
||||
"ON meeting_transcripts(page_id)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
cols = columns(conn, "collection_pages")
|
||||
if "external_event_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
|
||||
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
|
||||
"ON agent_approvals(status, created_at)"
|
||||
)
|
||||
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
user_cols = columns(conn, "users")
|
||||
if "totp_secret_enc" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
||||
if "totp_backup_hashes" not in user_cols:
|
||||
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
)
|
||||
for table in ("pages", "collections"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "teamspace_id" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
||||
|
||||
|
||||
+1
-18
@@ -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."""
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
triggers = conn.execute(
|
||||
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
|
||||
).fetchall()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
for trig in triggers:
|
||||
last = trig["last_fired_at"]
|
||||
if last:
|
||||
|
||||
+8
-3
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
@@ -46,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
now = datetime.utcnow().timestamp()
|
||||
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
|
||||
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
||||
if now - window_start > 60:
|
||||
_rate_limit_store[ip] = (now, 1)
|
||||
@@ -551,8 +551,13 @@ async def get_my_profile(request: Request):
|
||||
if not user:
|
||||
return {"login": "guest", "full_name": "Guest", "email": ""}
|
||||
with get_conn() as conn:
|
||||
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
|
||||
# password_hash, login_attempts et locked_until.
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, last_login, created_at "
|
||||
"FROM users WHERE login=?",
|
||||
(user.get("login", ""),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
|
||||
+77
-225
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import (
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
@@ -24,12 +24,14 @@ from app.services.api_v2_helpers import (
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v2", tags=["api-v2"])
|
||||
@@ -47,10 +49,8 @@ def _v2_rate_check(request: Request, user: dict) -> None:
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -110,10 +110,8 @@ async def list_tokens(request: Request, authorization: str | None = Header(defau
|
||||
|
||||
@router.delete("/tokens/{token_id}")
|
||||
async def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -127,10 +125,8 @@ async def revoke_token(token_id: int, request: Request, authorization: str | Non
|
||||
|
||||
@router.post("/tokens/{token_id}/rotate")
|
||||
async def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -171,10 +167,8 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
|
||||
|
||||
@router.patch("/users/me")
|
||||
async def patch_me(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -239,10 +233,8 @@ async def list_workspaces(request: Request, authorization: str | None = Header(d
|
||||
|
||||
@router.post("/workspaces")
|
||||
async def create_workspace(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
@@ -291,10 +283,8 @@ async def get_workspace(workspace_id: int, request: Request, authorization: str
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}")
|
||||
async def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -321,10 +311,8 @@ async def patch_workspace(workspace_id: int, request: Request, authorization: st
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}")
|
||||
async def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -349,10 +337,8 @@ async def list_workspace_members(workspace_id: int, request: Request, authorizat
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/members")
|
||||
async def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -389,10 +375,8 @@ async def invite_member(workspace_id: int, request: Request, authorization: str
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{uid}")
|
||||
async def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -417,10 +401,8 @@ async def update_member_role(workspace_id: int, uid: int, request: Request, auth
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{uid}")
|
||||
async def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
@@ -470,10 +452,8 @@ async def list_collections_v2(request: Request, authorization: str | None = Head
|
||||
|
||||
@router.post("/collections")
|
||||
async def create_collection_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
@@ -528,10 +508,8 @@ async def get_collection_v2(collection_id: int, request: Request, authorization:
|
||||
|
||||
@router.patch("/collections/{collection_id}")
|
||||
async def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -555,10 +533,8 @@ async def patch_collection_v2(collection_id: int, request: Request, authorizatio
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
async def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -570,10 +546,8 @@ async def delete_collection_v2(collection_id: int, request: Request, authorizati
|
||||
|
||||
@router.post("/collections/{collection_id}/linked")
|
||||
async def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -609,10 +583,8 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
|
||||
|
||||
@router.post("/collections/{collection_id}/task")
|
||||
async def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -636,10 +608,8 @@ async def list_sources(collection_id: int, request: Request, authorization: str
|
||||
|
||||
@router.post("/collections/{collection_id}/sources")
|
||||
async def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -662,10 +632,8 @@ async def add_source(collection_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.delete("/collections/{collection_id}/sources/{source_id}")
|
||||
async def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id))
|
||||
conn.commit()
|
||||
@@ -734,10 +702,8 @@ async def list_collection_pages_v2(collection_id: int, request: Request, authori
|
||||
|
||||
@router.post("/collections/{collection_id}/pages")
|
||||
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
@@ -816,10 +782,8 @@ async def get_page_v2(page_id: int, request: Request, authorization: str | None
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -865,10 +829,8 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -884,10 +846,8 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
|
||||
|
||||
@router.post("/pages/{page_id}/restore")
|
||||
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
@@ -903,10 +863,8 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
|
||||
|
||||
@router.post("/pages/{page_id}/move")
|
||||
async def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -934,10 +892,8 @@ async def list_sub_items_v2(page_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.post("/pages/{page_id}/sub-items")
|
||||
async def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -965,10 +921,8 @@ async def list_dependencies_v2(page_id: int, request: Request, authorization: st
|
||||
|
||||
@router.post("/pages/{page_id}/dependencies")
|
||||
async def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -992,10 +946,8 @@ async def add_dependency_v2(page_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
|
||||
async def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
|
||||
conn.commit()
|
||||
@@ -1016,10 +968,8 @@ async def list_properties_v2(collection_id: int, request: Request, authorization
|
||||
|
||||
@router.post("/collections/{collection_id}/properties")
|
||||
async def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1043,10 +993,8 @@ async def create_property_v2(collection_id: int, request: Request, authorization
|
||||
|
||||
@router.patch("/properties/{prop_id}")
|
||||
async def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1069,10 +1017,8 @@ async def patch_property_v2(prop_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.delete("/properties/{prop_id}")
|
||||
async def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone():
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -1083,10 +1029,8 @@ async def delete_property_v2(prop_id: int, request: Request, authorization: str
|
||||
|
||||
@router.post("/properties/{prop_id}/relation")
|
||||
async def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1158,10 +1102,8 @@ async def list_views_v2(collection_id: int, request: Request, authorization: str
|
||||
|
||||
@router.post("/collections/{collection_id}/views")
|
||||
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1185,10 +1127,8 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
|
||||
|
||||
@router.patch("/views/{view_id}")
|
||||
async def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1218,10 +1158,8 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
|
||||
|
||||
@router.delete("/views/{view_id}")
|
||||
async def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
|
||||
raise HTTPException(404, "View not found")
|
||||
@@ -1232,10 +1170,8 @@ async def delete_view_v2(view_id: int, request: Request, authorization: str | No
|
||||
|
||||
@router.post("/views/{view_id}/save-as")
|
||||
async def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1261,10 +1197,8 @@ async def list_dashboards_v2(collection_id: int, request: Request, authorization
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
async def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1279,10 +1213,8 @@ async def create_dashboard_v2(collection_id: int, request: Request, authorizatio
|
||||
|
||||
@router.patch("/dashboards/{dashboard_id}")
|
||||
async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1303,10 +1235,8 @@ async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization:
|
||||
|
||||
@router.delete("/dashboards/{dashboard_id}")
|
||||
async def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
|
||||
conn.commit()
|
||||
@@ -1326,10 +1256,8 @@ async def list_comments_v2(page_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1351,10 +1279,8 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.patch("/comments/{comment_id}")
|
||||
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1379,10 +1305,8 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1395,10 +1319,8 @@ async def delete_comment_v2(comment_id: int, request: Request, authorization: st
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1498,10 +1420,8 @@ async def list_favorites_v2(request: Request, authorization: str | None = Header
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1523,10 +1443,8 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
|
||||
|
||||
@router.delete("/favorites/{page_id}")
|
||||
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
|
||||
conn.commit()
|
||||
@@ -1550,10 +1468,8 @@ async def list_tags_v2(request: Request, authorization: str | None = Header(defa
|
||||
|
||||
@router.post("/tags")
|
||||
async def create_tag_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1573,10 +1489,8 @@ async def create_tag_v2(request: Request, authorization: str | None = Header(def
|
||||
|
||||
@router.patch("/tags/{tag_id}")
|
||||
async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1593,10 +1507,8 @@ async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
async def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
|
||||
conn.commit()
|
||||
@@ -1604,10 +1516,8 @@ async def delete_tag_v2(tag_id: int, request: Request, authorization: str | None
|
||||
|
||||
@router.post("/pages/{page_id}/tags")
|
||||
async def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1625,10 +1535,8 @@ async def attach_tag_v2(page_id: int, request: Request, authorization: str | Non
|
||||
|
||||
@router.delete("/pages/{page_id}/tags/{tag_id}")
|
||||
async def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
|
||||
conn.commit()
|
||||
@@ -1659,10 +1567,8 @@ async def list_shares_v2(page_id: int, request: Request, authorization: str | No
|
||||
|
||||
@router.post("/pages/{page_id}/shares")
|
||||
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1688,10 +1594,8 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
|
||||
|
||||
@router.patch("/shares/{share_id}")
|
||||
async def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1708,10 +1612,8 @@ async def patch_share_v2(share_id: int, request: Request, authorization: str | N
|
||||
|
||||
@router.delete("/shares/{share_id}")
|
||||
async def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1726,40 +1628,24 @@ async def delete_share_v2(share_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
slug = (body.get("slug") or body.get("publish_slug") or f"p-{page_id}-{secrets.token_urlsafe(6)}").strip()
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute("UPDATE pages SET is_published=1, publish_slug=?, is_shared=1 WHERE id=?", (slug, page_id))
|
||||
conn.commit()
|
||||
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
|
||||
slug, _title = publish(page_id, explicit_slug=slug_in)
|
||||
audit_log(user, "page.publish", "page", page_id, slug, request)
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page_v2")
|
||||
await fire_published(page_id, slug)
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET is_published=0 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page_v2")
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
# ── History ───────────────────────────────────────────────────────────────
|
||||
@@ -1776,10 +1662,8 @@ async def list_history_v2(page_id: int, request: Request, authorization: str | N
|
||||
|
||||
@router.post("/pages/{page_id}/history/restore")
|
||||
async def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1820,10 +1704,8 @@ async def list_sprints_v2(collection_id: int, request: Request, authorization: s
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1845,10 +1727,8 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
|
||||
|
||||
@router.patch("/sprints/{sprint_id}")
|
||||
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1872,10 +1752,8 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
|
||||
|
||||
@router.delete("/sprints/{sprint_id}")
|
||||
async def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
|
||||
conn.commit()
|
||||
@@ -1883,10 +1761,8 @@ async def delete_sprint_v2(sprint_id: int, request: Request, authorization: str
|
||||
|
||||
@router.post("/sprints/{sprint_id}/assign")
|
||||
async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1904,10 +1780,8 @@ async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str
|
||||
|
||||
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
|
||||
async def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
|
||||
conn.commit()
|
||||
@@ -1951,10 +1825,8 @@ async def list_templates_v2(collection_id: int, request: Request, authorization:
|
||||
|
||||
@router.post("/collections/{collection_id}/templates")
|
||||
async def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1970,10 +1842,8 @@ async def create_template_v2(collection_id: int, request: Request, authorization
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
async def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1991,10 +1861,8 @@ async def patch_template_v2(template_id: int, request: Request, authorization: s
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
|
||||
conn.commit()
|
||||
@@ -2002,10 +1870,8 @@ async def delete_template_v2(template_id: int, request: Request, authorization:
|
||||
|
||||
@router.post("/templates/{template_id}/apply")
|
||||
async def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
@@ -2027,10 +1893,8 @@ async def list_db_templates_v2(request: Request, authorization: str | None = Hea
|
||||
|
||||
@router.post("/templates/database/{template_id}/apply")
|
||||
async def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -2104,10 +1968,8 @@ async def export_csv_v2(collection_id: int, request: Request, authorization: str
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
@@ -2283,10 +2145,8 @@ async def list_webhooks_v2(request: Request, authorization: str | None = Header(
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -2313,10 +2173,8 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -2336,10 +2194,8 @@ async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
async def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
|
||||
conn.commit()
|
||||
@@ -2348,10 +2204,8 @@ async def delete_webhook_v2(webhook_id: int, request: Request, authorization: st
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -2399,10 +2253,8 @@ async def list_deliveries_v2(webhook_id: int, request: Request,
|
||||
async def retry_webhook_deliveries(webhook_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Manually retry failed deliveries for a webhook."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
|
||||
from app.services.webhook_outbound import retry_due_deliveries
|
||||
|
||||
|
||||
+14
-37
@@ -10,12 +10,14 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -850,15 +852,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
|
||||
"""Return groups from Gitea labels/milestones or fallback to defaults."""
|
||||
try:
|
||||
labels = json.loads(
|
||||
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
|
||||
) if False else []
|
||||
except Exception:
|
||||
labels = []
|
||||
return labels if labels else ["Design", "Engineering", "No Team"]
|
||||
|
||||
|
||||
async def asyncio_get_labels(owner: str, repo: str):
|
||||
@@ -1020,35 +1013,20 @@ async def update_share(request: Request, page_id: int):
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
import secrets
|
||||
slug = "p-" + secrets.token_urlsafe(8)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page")
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page")
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -1695,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
@@ -1732,7 +1709,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
@@ -2040,7 +2017,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
+11
-10
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
@@ -439,7 +441,10 @@ async def accounts_page(request: Request):
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
|
||||
users = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
@@ -1142,9 +1147,8 @@ def _file_page_disk_path(page: dict):
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
import os as _os
|
||||
from pathlib import Path
|
||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
||||
root = Path(settings.data_dir).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
@@ -1485,7 +1489,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), item_id),
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -1507,9 +1511,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
root = Path(settings.data_dir)
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
@@ -1567,11 +1570,10 @@ async def upload_local_workspace_file(request: Request):
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1655,11 +1657,10 @@ async def upload_local_workspace_folder(request: Request):
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
|
||||
if ext not in _IMAGE_EXTS:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
ws_id = _active_ws(request)
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+8
-61
@@ -2,15 +2,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
|
||||
return user
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a page title."""
|
||||
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
# ── Page Sharing ──
|
||||
|
||||
|
||||
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
slug = _slugify(page["title"])
|
||||
# Ensure uniqueness by appending suffix if needed
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page")
|
||||
|
||||
slug, _title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -346,25 +310,8 @@ async def publish_page(page_id: int, request: Request):
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
"""Unpublish a page."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page")
|
||||
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
@@ -399,7 +346,7 @@ async def track_recent(request: Request):
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
source_type=excluded.source_type,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
||||
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -407,5 +354,5 @@ async def track_recent(request: Request):
|
||||
"status": "tracked",
|
||||
"user_id": user["id"],
|
||||
"page_id": page_id,
|
||||
"accessed_at": datetime.utcnow().isoformat(),
|
||||
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -420,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
now = now or datetime.now(UTC).replace(tzinfo=None)
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
@@ -488,7 +488,7 @@ async def automation_scheduler():
|
||||
from app.services.workers import run_due_workers
|
||||
await run_due_workers()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("worker cron iteration failed")
|
||||
logger.warning("worker cron iteration failed")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
@@ -466,7 +466,7 @@ async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
|
||||
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
||||
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("calendar_sync_scheduler: %s", exc)
|
||||
logger.warning("calendar_sync_scheduler: %s", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
# ═══════════════ Helpers ═══════════════
|
||||
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
|
||||
|
||||
def _data_root() -> Path:
|
||||
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _file_meta(page: dict) -> dict:
|
||||
|
||||
@@ -35,7 +35,10 @@ class GiteaClient:
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||
now = datetime.now()
|
||||
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
|
||||
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
|
||||
self._cache[key] = (now + self._ttl, value)
|
||||
|
||||
# ── repos ──
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,11 +10,11 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.export import markdown_to_blocks
|
||||
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
|
||||
@@ -16,6 +16,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,7 +30,7 @@ class TranscriptionUnavailable(RuntimeError):
|
||||
|
||||
|
||||
def meetings_dir() -> Path:
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
root = Path(settings.data_dir)
|
||||
d = root / "uploads" / "meetings"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
|
||||
|
||||
Les trois surfaces divergeaient avant cette passe :
|
||||
|
||||
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
|
||||
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
|
||||
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
|
||||
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
|
||||
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
||||
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||
|
||||
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
||||
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
||||
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
||||
donc pas un partage manuel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
|
||||
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug
|
||||
|
||||
|
||||
def _unique_slug(conn, page_id: int, title: str) -> str:
|
||||
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
|
||||
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
|
||||
slug, counter = base, 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
||||
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
return slug, page["title"] or ""
|
||||
|
||||
|
||||
def unpublish(page_id: int) -> None:
|
||||
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def fire_published(page_id: int, slug: str) -> None:
|
||||
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish page.published")
|
||||
|
||||
|
||||
async def fire_unpublished(page_id: int) -> None:
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("publish page.unpublished")
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document #{pid} introuvable")
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), pid))
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -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` généré (402 chemins).
|
||||
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version` = 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`.
|
||||
|
||||
|
||||
+3092
-46
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,8 @@ jinja2==3.1.*
|
||||
python-multipart==0.0.*
|
||||
pydantic==2.10.*
|
||||
pydantic-settings==2.7.*
|
||||
aiosqlite==0.20.*
|
||||
loguru==0.7.*
|
||||
python-dotenv==1.0.*
|
||||
packaging>=24.0
|
||||
itsdangerous==2.2.*
|
||||
slowapi==0.1.*
|
||||
weasyprint==69.0
|
||||
xhtml2pdf==0.2.*
|
||||
openpyxl==3.1.*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,6 +36,185 @@ def test_public_view_hides_restricted_collection(client):
|
||||
assert "Internal" not in r.text
|
||||
|
||||
|
||||
def test_rate_limit_key_and_prune():
|
||||
"""A33 : XFF ignoré depuis une IP publique (anti-bypass), épurage du store."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.middleware.security import RateLimitMiddleware
|
||||
|
||||
mw = RateLimitMiddleware(None)
|
||||
|
||||
def req(host, fwd=None):
|
||||
headers = {"x-forwarded-for": fwd} if fwd else {}
|
||||
return SimpleNamespace(headers=headers, client=SimpleNamespace(host=host))
|
||||
|
||||
# IP publique (globale) : le client peut spoofer XFF autant qu'il veut → clé d'origine
|
||||
assert mw._client_key(req("8.8.8.8", "1.2.3.4")) == "8.8.8.8"
|
||||
# Derrière un proxy local : on prend le premier hop XFF
|
||||
assert mw._client_key(req("10.0.0.1", "198.51.100.7, 10.0.0.2")) == "198.51.100.7"
|
||||
# Sans XFF / hôte non IP (testserver)
|
||||
assert mw._client_key(req("testserver")) == "testserver"
|
||||
|
||||
# Épurage : les fenêtres expirées sortent du store
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
mw._store["old"] = (now - 3600, 5)
|
||||
mw._store["fresh"] = (now, 1)
|
||||
mw._prune(now)
|
||||
assert "old" not in mw._store and "fresh" in mw._store
|
||||
|
||||
|
||||
def test_cors_no_star(client):
|
||||
"""A37 : plus de `*` — origine refusée n'a pas d'ACAO, origine autorisée oui."""
|
||||
r = client.get("/api/health", headers={"Origin": "https://evil.example"})
|
||||
assert "access-control-allow-origin" not in r.headers
|
||||
r2 = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
|
||||
assert r2.headers.get("access-control-allow-origin") == "http://localhost:8080"
|
||||
|
||||
|
||||
def test_asset_version_single_source():
|
||||
"""A40 : une seule source de version d'assets = le fichier VERSION."""
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
version = (root / "VERSION").read_text(encoding="utf-8").strip()
|
||||
from app.templating import ASSET_VERSION, ENV
|
||||
|
||||
assert ASSET_VERSION == version
|
||||
assert ENV.globals["asset_version"] == version
|
||||
src = (root / "app/templates/base.html").read_text(encoding="utf-8")
|
||||
assert "app.css?v={{ asset_version }}" in src
|
||||
assert "app.js?v={{ asset_version }}" in src
|
||||
# plus aucun littéral de version première main dans les templates
|
||||
literals = _re.findall(
|
||||
r"(?:app|design-tokens|components|offline|flowdeck)\.(?:css|js)\?v=\d", src
|
||||
)
|
||||
assert literals == [], literals
|
||||
|
||||
|
||||
def test_publish_service_shared_and_safe(client):
|
||||
"""A29 : les 3 routers déléguent — 404 sur page absente, slug unique,
|
||||
dépublication qui ne touche pas aux partages manuels."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, share_mode) "
|
||||
"VALUES (1, 'Publie moi', 'contenu', 'markdown', 'anyone')",
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
try:
|
||||
r = client.post(f"/api/pages/{pid}/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
slug = r.json()["publish_slug"]
|
||||
assert slug # slugify du titre
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
|
||||
).fetchone()
|
||||
assert row["is_published"] == 1 and row["publish_slug"] == slug
|
||||
assert row["share_mode"] == "anyone" # intouché (share dialog propriétaire)
|
||||
|
||||
r2 = client.delete(f"/api/pages/{pid}/publish")
|
||||
assert r2.status_code == 200
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
|
||||
).fetchone()
|
||||
assert row["is_published"] == 0 and row["publish_slug"] == ""
|
||||
assert row["share_mode"] == "anyone" # dépublier ne révoque pas le partage
|
||||
|
||||
# 404 sur page inexistante — les deux chemins passent par le service
|
||||
assert client.post("/api/pages/999999/publish").status_code == 404
|
||||
assert client.delete("/api/pages/999999/publish").status_code == 404
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_users_me_no_secret_columns(client):
|
||||
"""A29-byproduct : GET /api/users/me (v1) ne doit plus renvoyer password_hash."""
|
||||
r = client.get("/api/users/me")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "password_hash" not in body, list(body)
|
||||
assert "locked_until" not in body and "login_attempts" not in body
|
||||
assert body.get("login") # la réponse reste exploitable
|
||||
|
||||
|
||||
def test_gitea_cache_evicts_expired():
|
||||
"""A42 : les entrées expirées sortent du cache à chaque écriture."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.services.gitea_client import GiteaClient
|
||||
|
||||
c = GiteaClient.__new__(GiteaClient) # sans appel réseau
|
||||
c._cache = {}
|
||||
c._ttl = timedelta(seconds=1)
|
||||
c._set_cache("k", "v")
|
||||
assert c._cached("k") == "v"
|
||||
# expire l'entrée puis force une autre écriture → la précédente est évacuée
|
||||
c._cache["k"] = (datetime.now() - timedelta(seconds=1), "v")
|
||||
c._set_cache("k2", "v2")
|
||||
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
|
||||
|
||||
|
||||
def test_migration_transaction_rolls_back():
|
||||
"""A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni
|
||||
ligne dans schema_version → la reprise rejoue proprement."""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
import pytest as _pytest
|
||||
|
||||
from app.migrations import _apply_one, _ensure_table
|
||||
|
||||
conn = _sqlite3.connect(":memory:")
|
||||
_ensure_table(conn)
|
||||
|
||||
def boom(c):
|
||||
c.execute("CREATE TABLE partial_x (id INTEGER)")
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with _pytest.raises(RuntimeError, match="boom"):
|
||||
_apply_one(conn, 9999, "boom", boom)
|
||||
assert (
|
||||
conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone()
|
||||
is None
|
||||
), "DDL partiel non annulé"
|
||||
assert (
|
||||
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0]
|
||||
== 0
|
||||
)
|
||||
# chemin nominal : DDL + marque de version dans la même transaction
|
||||
_apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)"))
|
||||
assert (
|
||||
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_columns_helper_validates_table_name():
|
||||
"""A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant."""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
from app.migrations import columns
|
||||
|
||||
conn = _sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)")
|
||||
assert columns(conn, "t1") == {"id", "nom"}
|
||||
try:
|
||||
columns(conn, "t1; DROP TABLE users")
|
||||
raise AssertionError("identifiant non validé")
|
||||
except ValueError:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
+2
-2
@@ -217,8 +217,8 @@ class TestV54TrashPurge:
|
||||
|
||||
with get_conn() as conn:
|
||||
# insert 2 deleted pages: one old, one recent
|
||||
old = datetime.datetime.utcnow() - datetime.timedelta(days=45)
|
||||
recent = datetime.datetime.utcnow() - datetime.timedelta(days=5)
|
||||
old = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=45)
|
||||
recent = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=5)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
|
||||
("test", "Old Page", old.isoformat()),
|
||||
|
||||
Reference in New Issue
Block a user