Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5fe0524a | ||
|
|
3bb8e87ef2 | ||
|
|
069c438aae | ||
|
|
45e59009c3 | ||
|
|
8d0d69e7b8 | ||
|
|
103bc57418 | ||
|
|
45917c194d | ||
|
|
ee1d46e965 | ||
|
|
587ec8d61b | ||
|
|
7a38ddd0f6 | ||
|
|
113374e499 | ||
|
|
0cb476e336 | ||
|
|
2339fa2586 | ||
|
|
8b48dbdd4b | ||
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 | ||
|
|
df9a269d76 | ||
|
|
da7326ffde | ||
|
|
3a1276596c | ||
|
|
07904f05e5 | ||
|
|
224bda74d5 | ||
|
|
c718fe06de | ||
|
|
f706424f90 | ||
|
|
7be96f0618 | ||
|
|
937ecfc2e0 | ||
|
|
998b5c630c | ||
|
|
cb47f5c7f4 | ||
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e | ||
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf | ||
|
|
72fcef2ba9 |
+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 ──
|
||||
|
||||
+757
@@ -1,5 +1,762 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A28 lot 1** : `app/routers/api_v2.py` (**2 110 lignes, 115 routes**)
|
||||
devient le package `app/routers/api_v2/` — un module par concern :
|
||||
· `collections` 566 L (23 routes), `engagement` 338 (21), `workspaces`
|
||||
230 (9), `templates_io` 205 (9), `webhooks` 195 (8), `identity` 195 (7),
|
||||
`views` 164 (8), `sharing` 160 (8), `properties` 151 (7),
|
||||
`planning` 148 (7), `projects` 93 (4), `admin` 91 (4)
|
||||
· `_common.py` : helpers partagés (`_hash`, `_v2_rate_check`)
|
||||
· `__init__.py` : `router = APIRouter(prefix="/api/v2")` + agrégat
|
||||
`include_router` (section routers sans prefix, tags `api-v2`)
|
||||
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
|
||||
byte-à-byte** (0 changement de chemin, tag ni operation_id)
|
||||
- Seul importateur (`app/main.py` → `from app.routers.api_v2 import
|
||||
router`) fonctionne via le package ; en-tête d'imports copié par module,
|
||||
émondé par `ruff --fix` (143 imports morts supprimés automatiquement)
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A28 : `dashboard.py` 2 735 L, `collections.py` 2 622 L,
|
||||
`board.py` 2 101 L (même recette, lots suivants)
|
||||
- Suite complète : **1091/1091** · ruff OK
|
||||
|
||||
## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé)
|
||||
|
||||
### Changed
|
||||
|
||||
- **`app/services/http_client.py`** : `async with shared_client(timeout=15)
|
||||
as client:` remplace les **49 créations `async with httpx.AsyncClient(`**
|
||||
réparties dans 14 fichiers (gitea ×21, providers ×11, calendar ×4,
|
||||
automations ×3, …) — le pool de connexions est réutilisé au lieu d'être
|
||||
recréé à chaque appel
|
||||
- Cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` : un
|
||||
`AsyncClient` n'est jamais partagé entre deux loops (le piège des tests :
|
||||
« Event loop is closed ») — une boucle par test = un client propre,
|
||||
collecté avec elle. Clé = kwargs triés, `repr()` pour les valeurs non
|
||||
hashables (`headers=` dict)
|
||||
- Context manager no-op à la sortie (pas de fermeture du client partagé) ;
|
||||
`ponytail:` documenté : pas d'`aclose` explicite, plafond = pools non
|
||||
fermés à la main (GC des sockets), upgrade = lifespan
|
||||
- **Laissés délibérément** : `github_adapter` (transport MockTransport
|
||||
injecté), `webhook_outbound` (client « own_client » fermé par la fonction)
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_http_client_shared_and_loop_scoped` : réutilisation (mêmes kwargs),
|
||||
cloisonnement par kwargs, cloisonnement par boucle (2× `asyncio.run`)
|
||||
- Stub webhooks : patch étendu à la fabrique `http_client.httpx` + purge du
|
||||
cache (les tests patchaient `webhook_outbound.httpx`, contourné par la
|
||||
fabrique partagée)
|
||||
- Suite complète : **1091/1091**
|
||||
|
||||
## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Vendorisation** : chart.js 4.5.1 + leaflet 1.9 (js, css, 5 images
|
||||
marker/layer) téléchargés vers `static/js/vendor/` ; les 3 URL CDN des
|
||||
vues chart/map (`collections.py`) pointent en local → **la CSP n'a plus
|
||||
aucun hôte tiers** dans `script-src` ni `style-src`
|
||||
- **`connect-src` fermé** : `'self' ws://{host} wss://{host}` (Host de la
|
||||
requête, caractères hors base URL filtrés) — le `https:` universel
|
||||
(canal d'exfil JS) et les `ws:`/`wss:` tout-hôtes disparaissent.
|
||||
Grep négatif : 0 fetch cross-origin côté front
|
||||
- **Google Fonts** : entrées CSP mortes (0 référence dans le code) retirées
|
||||
de `style-src`/`font-src`
|
||||
- `img-src https:` **conservé volontairement** (unfurls YouTube/Vimeo… et
|
||||
tuiles OSM inénumérables) — `ponytail:` commenté dans `security.py`
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_csp_no_cdn_and_vendor` : CSP sans CDN/Google, connect-src exact
|
||||
(`'self' ws://testserver wss://testserver`), 4 assets vendor servis (200),
|
||||
source `collections.py` sans référence CDN
|
||||
- `test_view_chart_renders` : assert sur le chemin vendor
|
||||
- Suite complète : **1090/1090**
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A20 : `unsafe-eval` (Alpine x-data + htmx hx-on/hx-vars = eval)
|
||||
→ build `@alpinejs/csp` + couverture E2E des vues d'abord (même logique
|
||||
que la décision A39)
|
||||
|
||||
## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 phase 2c** — **283 → 93 routes async** (**86 % des 667 routes** en
|
||||
threadpool, avant : 61 %) en 4 passes :
|
||||
· **Racine auth** : `get_current_user` (session.py) était `async def`
|
||||
**sans aucun await** (cookie decode = synchrone) ; idem ses clones
|
||||
(`agent._current_user_id/_workspace_id/_current_admin`,
|
||||
`sso._require_admin` au corps 0 await) → `def` + **47 `await`
|
||||
supprimés** (dont 3 via l'alias `gcu` — le piège : le grep littéral ne
|
||||
les voyait pas, la suite les a attrapés)
|
||||
· Re-scan : 19 routes devenues sans await → `def`
|
||||
· **155 routes** dont les seuls awaits étaient `request.json()` /
|
||||
événements → `Body(default={})` + `run_event_sync(...)` puis `def` :
|
||||
- try/except `body = {}` → défaut `{}` (même tolérance, laissée intacte)
|
||||
- try/except `raise HTTPException(400)` → `Body(...)` **requis**
|
||||
(422 FastAPI — **aucun test ne couvrait le 400**, aucun call front
|
||||
n'envoie de JSON invalide)
|
||||
- forme conditionnelle `request.json() if content-type else {}`
|
||||
(54 sites) → défaut `{}` : sans corps = `{}` dans les 2 cas
|
||||
- Import `Body`/`run_event_sync` ajoutés aux routers convertis
|
||||
|
||||
### Notes
|
||||
|
||||
- **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read`
|
||||
(corps réellement asynchrone), gitea/llm/oidc (réseau), `_json_body`
|
||||
(9 — wrapper de validation), 2 JSON inline en argument d'appel,
|
||||
1 fallback à logique (`capture_frontend_error`), 1 lecture conditionnelle
|
||||
de taille (web_clipper)
|
||||
- Suite complète : **1089/1089** · ruff OK
|
||||
|
||||
## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Toasts settings jamais affichés** : `settings.js` appelait
|
||||
`typeof toast === 'function'` — guard **toujours faux** (le `toast` global
|
||||
n'existait pas) → les retours « Timezone saved » / « SP metadata URL
|
||||
copiée » ne s'affichaient jamais. → `window.showToast` (global `base.html`)
|
||||
- `_wsInitData = window._wsInitData` (auto-affectation d'un nom non déclaré,
|
||||
sans effet observable car `window._wsInitData` existe depuis L4) supprimé
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 lint terminé : `eslint static/js` = 0 erreur / 0 warning** (285 → 0,
|
||||
22 fichiers) :
|
||||
· no-empty ×70 = `catch (x) {}` vides → `catch { /* volontaire */ }`
|
||||
(binding optionnel ES2019 ; zéro changement de comportement)
|
||||
· no-unused-vars ×171 = bindings de catch retirés + 24 lignes mortes
|
||||
déterministes (assert sur texte exact avant suppression) +
|
||||
`/* exported */` sur les 10 fonctions appelées depuis les attributs
|
||||
HTML des templates (vérifiées par grep)
|
||||
· no-undef ×44 = vrais globaux déclarés dans `eslint.config.mjs`
|
||||
(getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN)
|
||||
|
||||
### Notes
|
||||
|
||||
- `node --check` vert sur tous les fichiers ; suite complète **1089/1089**
|
||||
- A27 reste ouvert uniquement pour : `base` 1 523 L structurel (inline par
|
||||
nature, décision assumée) + ~500 L de petits blocs hors cibles
|
||||
|
||||
## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites**
|
||||
vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la
|
||||
construction de l'objet de config (4 clés + `{% if collection_data %}`) →
|
||||
config JSON `#db-config` **null-vs-objet** : `new DBInstance(container,
|
||||
PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja
|
||||
- 2 tests adaptés (lisaient le template source → `database_table.js`) ;
|
||||
`FlowDeckDB` plus dans le HTML → assert sur le JS
|
||||
|
||||
### Notes
|
||||
|
||||
- **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 +
|
||||
1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`,
|
||||
`node --check` vert partout, eslint **0 erreur / 285 warnings**
|
||||
- Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste
|
||||
inline par nature, décision assumée), ~500 L de petits blocs hors cibles,
|
||||
nettoyage des 285 warnings
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
|
||||
JSON (identique à la 2a) :
|
||||
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
|
||||
current_folder_id, workspace_id)
|
||||
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
|
||||
avatar_color, user full_name/login/email, is_admin, auth_method —
|
||||
**2 routes rendent ce template**, les expressions `or ""` sont préservées
|
||||
pour les valeurs Undefined, `is_admin` reste un booléen)
|
||||
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
|
||||
`rt-config` : SELF id/login/full_name/color)
|
||||
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
|
||||
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
|
||||
explicite) au lieu d'être interpolées dans des strings JS
|
||||
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
|
||||
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
|
||||
des fichiers extraits (try/catch → `{}`)
|
||||
|
||||
### Notes
|
||||
|
||||
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
|
||||
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
|
||||
(12 fichiers, baseline mise à jour)
|
||||
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
|
||||
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2a)** — `_page_editor_scripts.html` : les **2 516 lignes** du
|
||||
gros bloc interpolé partent vers `static/js/page_editor_scripts.js`
|
||||
- Recette « config JSON » : les **8 interpolations Jinja** lisent maintenant
|
||||
`PD = JSON.parse(document.getElementById('page-data'))` — le bloc JSON
|
||||
`#page-data` **existait déjà** juste avant le script, même ordre
|
||||
d'exécution, garde `__fdEditorScriptsLoaded` préservée
|
||||
- Côté route (`view_page_root`) : `page_data` enrichi de `updated_at`,
|
||||
`created_at`, `user_id`, `is_shared` (le dérivé est **hoisté** — une seule
|
||||
expression sert le ctx ET le JSON) et `clip_icon` (macro `fd_icon` rendue
|
||||
côté serveur). `workspace_key` reste vide comme avant (jamais défini dans
|
||||
ce contexte → parité stricte)
|
||||
- `node --check` vert ; template : 2 tags restants (JSON config + src)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **8 tests adaptés** à l'extraction (ils lisaient le template source) :
|
||||
`test_ai_writing` ×2 (+ helper `_read_js`), `test_pwa_offline`,
|
||||
`test_v511` front_end_wired, `test_v55` ×3 (lightbox, previews, endpoints)
|
||||
→ lisent `static/js/page_editor_scripts.js` ; `test_page_editor_renders_
|
||||
page_is_shared` → **parsing du JSON `#page-data`** (`is_shared is True`,
|
||||
la valeur sert toujours à la page)
|
||||
|
||||
### Notes
|
||||
|
||||
- Cumul A27 : **6 759 L extraites** (13 904 → 7 145 inline)
|
||||
- Reste : local_workspace 2 031, base 1 523 (structurel {% for %}/{% block %}),
|
||||
database_table 1 323 (if/else), settings 1 093, realtime 531, board 146
|
||||
≈ 6 653 L + 120 warnings eslint à nettoyer
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.21.0 (2026-10-01) — Audit : A27 phase 1 (4 243 L de JS extraites)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 1)** — les 7 templates dont le JS **n'est pas interpolé Jinja**
|
||||
sont extraits vers `static/js/*.js` : agent_panel_1/_2 (dont un bloc de
|
||||
**1 788 lignes livré sur chaque page**), library (1 039), gitea_workspace
|
||||
(626), _icon_picker_1/_2, _ctx_menu, import, workspaces →
|
||||
**4 243 lignes, -30 % de JS inline** (13 904 → 9 661)
|
||||
- Extraction **un fichier par bloc** : ordre et timing d'exécution identiques
|
||||
(pas de defer/async, attributs d'origine conservés dont `data-cfasync`),
|
||||
cache-busting `?v={{ asset_version }}` (source unique A40), CSP : les
|
||||
scripts externes relèvent de `'self'` (pas de nonce requis)
|
||||
|
||||
### Lint
|
||||
|
||||
- ESLint **installé globalement** (`npm i -g eslint`) — `eslint.config.mjs`
|
||||
existait déjà en flat config « sans dépendances » mais **aucun binaire**
|
||||
n'était installé (d'où « 0 linté »)
|
||||
- `eslint static/js` : **0 erreur, 120 warnings** sur 8 fichiers
|
||||
(no-unused-vars 69, no-empty 36, no-undef 15) → baseline à nettoyer
|
||||
- `node --check` vert sur les 9 fichiers extraits ; `eslint.config.mjs` couvre
|
||||
déjà `static/js/**/*.js` donc les extraits sont lintés d'office
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A27 : les blocs interpolés Jinja (page_editor 2 517, local_workspace
|
||||
2 031, base 1 523, database_table 1 323, settings 1 093… ≈ 9 661 L) →
|
||||
extraction en 2 temps (config JSON injectée + script statique)
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
|
||||
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
|
||||
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
|
||||
code) → `{% from '_icons.html' import fd_icon %}` ajouté
|
||||
|
||||
### Tests
|
||||
|
||||
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
|
||||
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
|
||||
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
|
||||
aurait masqué la mise à jour)
|
||||
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
|
||||
recalculée **sans perdre la carte**
|
||||
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
|
||||
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
|
||||
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
|
||||
base**, 404 sans board
|
||||
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
|
||||
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
|
||||
sidebar_config, github_routes, library, api, dashboard, api_v2)
|
||||
|
||||
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
|
||||
|
||||
### Tests
|
||||
|
||||
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
|
||||
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
|
||||
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
|
||||
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
|
||||
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
|
||||
· `upload-folder` : **validations seules** (structure absente → 400,
|
||||
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
|
||||
· `convert-to-database` : collection + propriété `title` + vue `table` +
|
||||
page en `content_format='collection'` **vérifiés en base**, 404 page
|
||||
inconnue, cleanup **dans l'ordre FK** (page avant collection)
|
||||
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
|
||||
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
|
||||
paths en f-string dans les tests, rapprochés manuellement)
|
||||
- Suite complète : **1086/1086**
|
||||
|
||||
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
|
||||
|
||||
### Tests
|
||||
|
||||
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
|
||||
`test_smoke_uncovered.py` : 46 tests :
|
||||
· `/gitea-workspace` : page HTML (200 ou redirection propre)
|
||||
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
|
||||
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
|
||||
nom, nettoyage
|
||||
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
|
||||
base** → move → soft-delete (`deleted_at` **relu**) → restore
|
||||
(`deleted_at IS NULL` **relu**), nettoyage
|
||||
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
|
||||
liste workspace, search (shape), suppression vérifiée. Utilisateur +
|
||||
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
|
||||
d'un workspace actif (fallback « premier workspace du user ») — on ne le
|
||||
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
|
||||
- Suite complète : **1083/1083**
|
||||
|
||||
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
|
||||
|
||||
### Tests
|
||||
|
||||
- +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les
|
||||
garde-fous A16 — `test_smoke_uncovered.py` : 42 tests :
|
||||
· `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` →
|
||||
**403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit
|
||||
dans le data_dir de test → **200 + octets exacts** (nettoyé)
|
||||
· `GET /api/pages/{id}/download` : page markdown → 404 « downloadable »
|
||||
(pas de 500) ; page « file » avec chemin `../` qui sort de la racine →
|
||||
**jamais 200** (404), et `file-content` → 404/415
|
||||
· `GET /api/local-workspace/page-content/{id}` : contenu + format relus,
|
||||
404 sur id inconnu
|
||||
· `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False`
|
||||
(AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans
|
||||
avatar
|
||||
· `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape,
|
||||
ligne créée **retrouvée dans table-data**, nettoyage finally
|
||||
- Suite complète : **1079/1079**
|
||||
|
||||
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
|
||||
|
||||
### Tests
|
||||
|
||||
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
|
||||
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
|
||||
du rendu (whitelist A2 vérifiée côté page)
|
||||
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
|
||||
restaurée en `finally`
|
||||
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
|
||||
la session seule ne change pas le mdp) + quirk assumé et documenté :
|
||||
longueur validée AVANT auth → 200 + message d'erreur
|
||||
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
|
||||
nettoyée en `finally`
|
||||
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
|
||||
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
|
||||
court** (validateur), restauration en `finally`
|
||||
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
|
||||
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
|
||||
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
|
||||
|
||||
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
|
||||
|
||||
### Tests
|
||||
|
||||
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
|
||||
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
|
||||
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
|
||||
dans la liste, changement de couleur relu, suppression puis absence
|
||||
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
|
||||
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
|
||||
(`parent_section='Trash'` + `deleted_at` **relus en base**)
|
||||
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
|
||||
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
|
||||
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
|
||||
· `workspace/{id}/members` : shape `{"members": [...]}`
|
||||
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
|
||||
- Suite complète : **1069/1069**
|
||||
|
||||
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
|
||||
|
||||
### Tests
|
||||
|
||||
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
|
||||
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
|
||||
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
|
||||
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
|
||||
route/auth/parse, pas le moteur)
|
||||
· `POST /properties/compute-rollup` : 400 `collection_id required`,
|
||||
401 sans bearer
|
||||
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
|
||||
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
|
||||
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
|
||||
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
|
||||
· `POST /webhooks/verify-signature` : **valid=True** avec
|
||||
`sign_payload(secret, payload)` (même helper que le serveur), False avec
|
||||
une signature bidon
|
||||
- `test_smoke_uncovered.py` : 27 tests au total
|
||||
- Suite complète : **1064/1064**
|
||||
|
||||
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
|
||||
|
||||
### Tests
|
||||
|
||||
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
|
||||
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
|
||||
relecture du roundtrip
|
||||
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
|
||||
suppression vérifiée
|
||||
· `card` POST : 404 sans board, `{"status": "ok"}` avec
|
||||
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
|
||||
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
|
||||
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
|
||||
· checklist mutations : PATCH item (checked/content relus EN BASE),
|
||||
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
|
||||
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
|
||||
stub de transport httpx. Reste global : `dashboard.py` 17/63,
|
||||
`api_v2.py` 50/115
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
|
||||
|
||||
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
|
||||
|
||||
### Tests
|
||||
|
||||
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
|
||||
(recents/favorites/published/private/workspace) en un test de boucle,
|
||||
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
|
||||
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
|
||||
(aucun appel réseau — la clé n'est qu'une string de workspace)
|
||||
- Test de non-régression 404 sur les 2 routes supprimées
|
||||
|
||||
### Removed
|
||||
|
||||
- **2 routes cassées supprimées** (découverte des smokes) :
|
||||
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
|
||||
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
|
||||
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
|
||||
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
|
||||
`library._format_size` devenu mort : supprimé aussi (une version vit dans
|
||||
`dashboard.py`, inchangée)
|
||||
- `local_workspace_items` : plus aucune occurrence dans `app/`
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
|
||||
|
||||
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
|
||||
routers qui n'avaient AUCUN test :
|
||||
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
|
||||
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
|
||||
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
|
||||
(zéro accès réseau réel)
|
||||
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
|
||||
échappement HTML vérifié `<b>`)
|
||||
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
|
||||
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
|
||||
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
|
||||
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
|
||||
`dashboard.py` 17/63, `api_v2.py` 50/115
|
||||
- Suite complète : **1047/1047**
|
||||
|
||||
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
|
||||
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
|
||||
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
|
||||
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
|
||||
(déterministe, équivalent sémantique de l'`await` d'avant). Note
|
||||
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
|
||||
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
|
||||
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
|
||||
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
|
||||
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
|
||||
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
|
||||
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
|
||||
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
|
||||
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
|
||||
|
||||
### Tests
|
||||
|
||||
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
|
||||
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
|
||||
en 228 s
|
||||
|
||||
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
|
||||
était `body = await request.json()` passent à un paramètre FastAPI
|
||||
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
|
||||
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
|
||||
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
|
||||
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
|
||||
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
|
||||
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
|
||||
il ne reste que **19 routes async** dans ce router (`fire_event`,
|
||||
`request.form`, appels gitea/webhooks — phase 2b)
|
||||
- Bug de transformation évité en cours de route : première version du script
|
||||
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
|
||||
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
|
||||
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
|
||||
`test_v65` + audit : 62/62 verts avant la passe complète
|
||||
|
||||
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties
|
||||
en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail
|
||||
SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une
|
||||
ligne de logique** (la conversion est sémantiquement neutre : vérifié corps
|
||||
par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les
|
||||
fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25,
|
||||
board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers
|
||||
- **Reste (phase 2)** — les 311 routes qui ont de vrais `await`
|
||||
(`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans
|
||||
`await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour
|
||||
l'instant (rien ne l'appellerait)
|
||||
|
||||
### Perf
|
||||
|
||||
- Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les
|
||||
handlers SQLite ne saturent plus la boucle pendant les tests
|
||||
|
||||
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
|
||||
|
||||
### Security
|
||||
|
||||
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
|
||||
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
|
||||
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
|
||||
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
|
||||
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
|
||||
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
|
||||
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
|
||||
- Les 74 handlers `onclick=` inline restent fonctionnels via
|
||||
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
|
||||
aurait désactivés aussi)
|
||||
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
|
||||
`style-src` : les vues chart/map de `collections` les utilisent et étaient
|
||||
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
|
||||
vendoriser ces libs puis retirer les hôtes)
|
||||
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
|
||||
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
|
||||
`img-src`/`connect-src`
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
|
||||
header identique sur tous les scripts inline, nonce différent d'une requête à
|
||||
l'autre) et page hors template (`/auth/login?provider=local`)
|
||||
|
||||
## 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
|
||||
|
||||
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
|
||||
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
|
||||
d'événements/webhooks et des écritures sont enfin visibles dans les logs
|
||||
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
|
||||
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
|
||||
transaction au lieu de commiter une collection sans schéma
|
||||
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
|
||||
point d'entrée des connexions) ; le wrapper async + les 510 call sites
|
||||
synchrones sur l'event loop restent à migrer
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
|
||||
|
||||
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
|
||||
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
|
||||
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
|
||||
`ping()` du serveur vers un `api_base` interne
|
||||
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
|
||||
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
|
||||
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
|
||||
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
|
||||
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
|
||||
|
||||
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
|
||||
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
|
||||
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
|
||||
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
|
||||
préfixes sortent d'`EXCLUDED_PATHS`
|
||||
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
|
||||
(interpolations Jinja neutralisées) — 0 échec avant/après
|
||||
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
|
||||
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
|
||||
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
|
||||
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
|
||||
|
||||
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
|
||||
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
|
||||
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
|
||||
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
|
||||
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
|
||||
`welcome.html` équipés du header
|
||||
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
|
||||
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
|
||||
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
|
||||
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
|
||||
route, pas le 403 du middleware) → suite **1026/1026**
|
||||
|
||||
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A16** — `_load_page_or_404` (4 exports) et les 2 routes pièce jointe
|
||||
(`/download`, `/file-content`) passent par session + `PermissionManager.can_view_page`
|
||||
→ 401 sans session, 404 hors ACL ; la lecture legacy `board.py` était déjà
|
||||
couverte par A7
|
||||
- Test `test_exports_and_attachments_require_auth` → suite **1026/1026**
|
||||
|
||||
## v7.3.3 (2026-09-30) — Audit sécurité : A12–A24 (SSRF, auth legacy, uploads, perf)
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+24
-28
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.3 (audit sécurité A1–A24) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **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
|
||||
|
||||
+3
-4
@@ -4,9 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +38,7 @@ class GiteaOAuth:
|
||||
async def exchange_code(self, code: str) -> dict | None:
|
||||
"""Exchange authorization code for access token."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
@@ -61,7 +60,7 @@ class GiteaOAuth:
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
"""Get user info from Gitea API."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
self.USER_URL,
|
||||
headers={"Authorization": f"token {access_token}"},
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,7 +76,7 @@ class GiteaProvider(OAuthProvider):
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
||||
if r.status_code != 200:
|
||||
logger.error("Gitea token exchange failed: %s", r.text)
|
||||
@@ -85,7 +85,7 @@ class GiteaProvider(OAuthProvider):
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.base}/api/v1/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
@@ -100,7 +100,7 @@ class GiteaProvider(OAuthProvider):
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with shared_client(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.base}/api/v1/user/repos",
|
||||
@@ -158,7 +158,7 @@ class GitHubProvider(OAuthProvider):
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
@@ -179,7 +179,7 @@ class GitHubProvider(OAuthProvider):
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.api_url}/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
@@ -197,7 +197,7 @@ class GitHubProvider(OAuthProvider):
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with shared_client(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.api_url}/user/repos",
|
||||
|
||||
@@ -15,7 +15,7 @@ import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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:
|
||||
@@ -61,7 +59,7 @@ async def discover(issuer_url: str) -> dict:
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
@@ -114,7 +112,7 @@ async def exchange_code(
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
@@ -135,7 +133,7 @@ async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
|
||||
+5
-5
@@ -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:
|
||||
@@ -171,11 +171,11 @@ def _touch_session(sid: str) -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_touch_session")
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
async def get_current_user(request) -> dict | None:
|
||||
def get_current_user(request) -> dict | None:
|
||||
"""FastAPI dependency: extract current user from session cookie."""
|
||||
session = request.cookies.get("flowdeck_session")
|
||||
if session:
|
||||
|
||||
+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")
|
||||
|
||||
|
||||
|
||||
@@ -837,6 +837,11 @@ def get_conn():
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
|
||||
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
|
||||
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
|
||||
# l'event loop) reste à migrer module par module.
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
|
||||
+64
-17
@@ -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.3",
|
||||
version="7.29.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)
|
||||
@@ -218,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
def pwa_manifest():
|
||||
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
def service_worker():
|
||||
"""Serve the PWA service worker at top-level scope (/)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||
@@ -235,7 +282,7 @@ async def service_worker():
|
||||
|
||||
|
||||
@app.get("/api/csrf-token")
|
||||
async def csrf_token_endpoint(request: Request):
|
||||
def csrf_token_endpoint(request: Request):
|
||||
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||
import secrets
|
||||
|
||||
@@ -250,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
|
||||
|
||||
|
||||
@app.get("/api/pages")
|
||||
async def api_pages_alias(request: Request):
|
||||
def api_pages_alias(request: Request):
|
||||
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
qs = str(request.url.query)
|
||||
@@ -259,7 +306,7 @@ async def api_pages_alias(request: Request):
|
||||
|
||||
|
||||
@app.post("/api/pages")
|
||||
async def api_pages_post_alias(request: Request):
|
||||
def api_pages_post_alias(request: Request):
|
||||
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||
@@ -294,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
|
||||
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
|
||||
+15
-1
@@ -18,7 +18,21 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
||||
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"}
|
||||
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
||||
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
||||
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
||||
# library, gitea_workspace, workspace, workspaces, welcome).
|
||||
# Ne restent que du machine-to-machine / hors session :
|
||||
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
|
||||
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
|
||||
# - publics : /s/ (sites), /f/ (forms)
|
||||
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
||||
EXCLUDED_PATHS = {
|
||||
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
|
||||
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
|
||||
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
|
||||
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
+95
-11
@@ -1,6 +1,9 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -8,6 +11,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────
|
||||
|
||||
# Allowed extensions for file uploads
|
||||
@@ -62,13 +67,32 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
CSP_HEADER = "Content-Security-Policy"
|
||||
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
# ponytail: `https:` reste ouvert — unfurls (YouTube/Vimeo/…) et
|
||||
# tuiles OSM sont inénumérables ; plafond assumé, à resserrer si
|
||||
# un proxy d'images local arrive.
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"font-src 'self' data:; "
|
||||
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
|
||||
# côté front — grep négatif) et websockets scopés à l'hôte de la
|
||||
# requête ({host}) → plus de canal d'exfil vers un tiers.
|
||||
"connect-src 'self' ws://{host} wss://{host}; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
@@ -77,11 +101,22 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
|
||||
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
|
||||
# exactement ce que les templates liront via `csp_nonce()`.
|
||||
CSP_NONCE.set(nonce)
|
||||
response = await call_next(request)
|
||||
# Only set CSP on HTML responses
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "text/html" in content_type:
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE
|
||||
# Host du navigateur (uvicorn rejette les Host invalides) ;
|
||||
# on retire quand même tout caractère hors base URL par sécurité.
|
||||
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
|
||||
request.headers.get("host", ""))
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
||||
nonce=nonce, host=host
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -97,8 +132,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 +149,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 +167,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
|
||||
|
||||
+62
-29
@@ -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:
|
||||
@@ -862,7 +895,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_migration_v630_api_v2")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -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."""
|
||||
|
||||
+8
-16
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
@@ -8,7 +8,7 @@ router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
# ── Dependency ──
|
||||
async def admin_required(request: Request):
|
||||
from app.auth.session import get_current_user
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
|
||||
|
||||
# ── Users ──
|
||||
@router.get("/users")
|
||||
async def list_users(_admin=Depends(admin_required)):
|
||||
def list_users(_admin=Depends(admin_required)):
|
||||
"""List all users with workspace/file/folder counts and storage usage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -46,15 +46,11 @@ async def list_users(_admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Create a new user (admin only)."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = body.get("login", "").strip()
|
||||
name = body.get("name", login)
|
||||
email = body.get("email", login)
|
||||
@@ -78,15 +74,11 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.put("/users/{user_id:int}")
|
||||
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||
def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
@@ -109,7 +101,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
|
||||
|
||||
|
||||
@router.delete("/users/{user_id:int}")
|
||||
async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -139,7 +131,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
async def user_stats(_admin=Depends(admin_required)):
|
||||
def user_stats(_admin=Depends(admin_required)):
|
||||
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -159,7 +151,7 @@ async def user_stats(_admin=Depends(admin_required)):
|
||||
|
||||
# ── Audit ──
|
||||
@router.get("/audit")
|
||||
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
"""Recent login history."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
|
||||
+111
-101
@@ -7,8 +7,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.session import get_current_user
|
||||
@@ -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:
|
||||
@@ -92,17 +93,16 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
logger.exception("Agent scheduler tick failed")
|
||||
|
||||
|
||||
async def _current_user_id(request: Request) -> int | None:
|
||||
user = await get_current_user(request)
|
||||
if user and user.get("id"):
|
||||
return user["id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
return row["id"] if row else None
|
||||
def _current_user_id(request: Request) -> int:
|
||||
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||
user = get_current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
async def _workspace_id(request: Request) -> int | None:
|
||||
user = await get_current_user(request)
|
||||
def _workspace_id(request: Request) -> int | None:
|
||||
user = get_current_user(request)
|
||||
if user and user.get("workspace_id"):
|
||||
return user["workspace_id"]
|
||||
try:
|
||||
@@ -112,23 +112,20 @@ async def _workspace_id(request: Request) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _current_admin(request: Request) -> dict:
|
||||
"""Require an admin session. Falls back to the single admin row, matching
|
||||
the agent router's unauthenticated convention (single-user deployments)."""
|
||||
user = await get_current_user(request)
|
||||
if user:
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return dict(row)
|
||||
def _current_admin(request: Request) -> dict:
|
||||
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
|
||||
|
||||
def _default_agent(conn, user_id: int) -> dict:
|
||||
@@ -149,9 +146,9 @@ def _default_agent(conn, user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def list_agents(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user_id)
|
||||
rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall()
|
||||
@@ -159,10 +156,9 @@ async def list_agents(request: Request):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_agent(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_agent(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
@@ -187,8 +183,8 @@ async def create_agent(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
def list_conversations(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC",
|
||||
@@ -198,10 +194,9 @@ async def list_conversations(request: Request):
|
||||
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_conversation(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
ws = await _workspace_id(request)
|
||||
def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
cur = conn.execute(
|
||||
@@ -216,7 +211,7 @@ async def create_conversation(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_conversation(request: Request, conversation_id: int):
|
||||
def get_conversation(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
||||
if not conv:
|
||||
@@ -229,7 +224,7 @@ async def get_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def delete_conversation(request: Request, conversation_id: int):
|
||||
def delete_conversation(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||
@@ -239,10 +234,9 @@ async def delete_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}")
|
||||
async def patch_conversation(request: Request, conversation_id: int):
|
||||
def patch_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
"""Update a conversation's title / provider / model (slash-command support)."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute(
|
||||
"SELECT id FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
@@ -265,10 +259,9 @@ async def patch_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/run")
|
||||
async def run_conversation(request: Request, conversation_id: int):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
async def run_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
objective = (body.get("message") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(status_code=400, detail="message est requis")
|
||||
@@ -326,7 +319,7 @@ async def agent_generate(request: Request):
|
||||
Because no tool schema is offered, the model answers with plain text based on
|
||||
the provided document context instead of issuing search_workspace / tools.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prompt = (body.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
@@ -386,7 +379,7 @@ async def agent_writing(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
action = (body.get("action") or "").strip().lower()
|
||||
if not action:
|
||||
@@ -425,7 +418,7 @@ async def agent_writing_properties(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
properties = body.get("properties") or []
|
||||
if not isinstance(properties, list) or not properties:
|
||||
@@ -453,7 +446,7 @@ async def agent_writing_properties(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/actions")
|
||||
async def list_actions(request: Request, conversation_id: int):
|
||||
def list_actions(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
@@ -463,7 +456,7 @@ async def list_actions(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/actions/{action_id}/undo")
|
||||
async def undo(request: Request, action_id: int):
|
||||
def undo(request: Request, action_id: int):
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
@@ -477,18 +470,17 @@ async def undo(request: Request, action_id: int):
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills(request: Request):
|
||||
ws = await _workspace_id(request)
|
||||
def list_skills(request: Request):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall()
|
||||
return {"skills": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_skill(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
@@ -507,10 +499,10 @@ async def create_skill(request: Request):
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill(request: Request, skill_id: int):
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
@@ -531,20 +523,19 @@ async def apply_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery(request: Request):
|
||||
def skills_gallery(request: Request):
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill(request: Request, slug: str):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def install_gallery_skill(request: Request, slug: str, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
@@ -558,11 +549,10 @@ async def install_gallery_skill(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill(request: Request):
|
||||
def import_skill(request: Request, body: dict = Body(default={})):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
@@ -580,7 +570,7 @@ async def import_skill(request: Request):
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill(request: Request, skill_id: int):
|
||||
def export_skill(request: Request, skill_id: int):
|
||||
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
@@ -590,7 +580,7 @@ async def export_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill(request: Request, skill_id: int):
|
||||
def delete_skill(request: Request, skill_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -604,7 +594,7 @@ async def delete_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.get("/mentions")
|
||||
async def list_mentions(request: Request, q: str = ""):
|
||||
def list_mentions(request: Request, q: str = ""):
|
||||
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
|
||||
|
||||
Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au
|
||||
@@ -627,7 +617,7 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
except (TypeError, ValueError):
|
||||
ws = None
|
||||
if not ws:
|
||||
ws = await _workspace_id(request)
|
||||
ws = _workspace_id(request)
|
||||
|
||||
def dedupe(items: list[dict]) -> list[dict]:
|
||||
seen: set = set()
|
||||
@@ -732,10 +722,9 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def add_feedback(request: Request):
|
||||
def add_feedback(request: Request, body: dict = Body(default={})):
|
||||
"""Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
rating = (body.get("rating") or "").strip().lower()
|
||||
if rating not in ("up", "down"):
|
||||
raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'")
|
||||
@@ -769,8 +758,8 @@ async def add_feedback(request: Request):
|
||||
async def trigger_agent(request: Request, agent_id: int):
|
||||
"""Manually fire a custom agent: create a conversation and run it with the
|
||||
agent's instructions as the objective (falls back to a generic prompt)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
@@ -797,7 +786,7 @@ async def trigger_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def list_tools(request: Request):
|
||||
def list_tools(request: Request):
|
||||
registry = ToolRegistry()
|
||||
tools = registry.schema()
|
||||
return {"tools": tools}
|
||||
@@ -817,7 +806,7 @@ async def list_providers(request: Request):
|
||||
- ``verified`` : the last connection test / model fetch succeeded.
|
||||
- ``functional`` : the provider is ready to chat (verified, or `offline`).
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
llm = LLMClient()
|
||||
cfg = get_llm_config()
|
||||
keys = list_user_llm_keys(user_id)
|
||||
@@ -872,20 +861,19 @@ async def list_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_llm_keys(request: Request):
|
||||
def list_llm_keys(request: Request):
|
||||
"""The user's saved provider keys + API keys (masked)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
return {"keys": list_user_llm_keys(user_id)}
|
||||
|
||||
|
||||
@router.put("/keys/{llm_provider}")
|
||||
async def save_llm_key(request: Request, llm_provider: str):
|
||||
def save_llm_key(request: Request, llm_provider: str, body: dict = Body(default={})):
|
||||
"""Upsert a provider key for the current user (masked in responses)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
api_base_raw = body.get("api_base")
|
||||
raw = upsert_user_llm_key(
|
||||
user_id,
|
||||
@@ -901,9 +889,9 @@ async def save_llm_key(request: Request, llm_provider: str):
|
||||
|
||||
|
||||
@router.delete("/keys/{llm_provider}")
|
||||
async def delete_llm_key(request: Request, llm_provider: str):
|
||||
def delete_llm_key(request: Request, llm_provider: str):
|
||||
"""Remove a saved provider key for the current user."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -918,7 +906,7 @@ async def test_user_llm_key(request: Request, llm_provider: str):
|
||||
On success the provider is flagged ``verified`` so it can be offered in the
|
||||
Agent panel; on failure the stored error is kept for display in Settings.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -973,7 +961,7 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
A successful fetch proves connectivity, so when it used the *stored* key the
|
||||
provider is flagged ``verified`` (functional) for the Agent panel.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -997,9 +985,32 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
return {"ok": False, "provider": provider, "error": str(exc)}
|
||||
|
||||
|
||||
def _check_api_base(value: str) -> str:
|
||||
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
|
||||
|
||||
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
|
||||
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
|
||||
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
|
||||
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
|
||||
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
|
||||
settings `llm_allow_private=false`.
|
||||
"""
|
||||
url = (value or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
|
||||
return url
|
||||
|
||||
|
||||
@router.patch("/providers")
|
||||
async def update_provider_config(request: Request):
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower()
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -1008,7 +1019,7 @@ async def update_provider_config(request: Request):
|
||||
provider=provider or None,
|
||||
model=(body.get("model") or "").strip() or None,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
clear_keys=(provider == "offline"),
|
||||
)
|
||||
llm = LLMClient()
|
||||
@@ -1029,7 +1040,7 @@ async def test_provider_config(request: Request):
|
||||
A successful test flags the workspace default provider as ``verified`` so it
|
||||
becomes available (functional) for every user in the Agent panel.
|
||||
"""
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower() or None
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -1037,7 +1048,7 @@ async def test_provider_config(request: Request):
|
||||
llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
)
|
||||
try:
|
||||
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||
@@ -1061,7 +1072,7 @@ async def test_provider_config(request: Request):
|
||||
|
||||
|
||||
@router.get("/{agent_id}")
|
||||
async def get_agent(request: Request, agent_id: int):
|
||||
def get_agent(request: Request, agent_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1070,8 +1081,7 @@ async def get_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.put("/{agent_id}")
|
||||
async def update_agent(request: Request, agent_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_agent(request: Request, agent_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
@@ -1096,7 +1106,7 @@ async def update_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.delete("/{agent_id}")
|
||||
async def delete_agent(request: Request, agent_id: int):
|
||||
def delete_agent(request: Request, agent_id: int):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
|
||||
+24
-19
@@ -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)
|
||||
@@ -67,12 +67,12 @@ async def health(request: Request):
|
||||
conn.execute("SELECT 1")
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("health")
|
||||
try:
|
||||
await gitea.get_user_repos(page=1, limit=1)
|
||||
gitea_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("health")
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
@@ -83,7 +83,7 @@ async def health(request: Request):
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats():
|
||||
def stats():
|
||||
"""Global stats for dashboard."""
|
||||
with get_conn() as conn:
|
||||
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
||||
@@ -179,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
|
||||
|
||||
|
||||
@router.post("/col-mapping")
|
||||
async def set_col_mapping(
|
||||
def set_col_mapping(
|
||||
owner: str = Query(...),
|
||||
repo: str = Query(...),
|
||||
column: str = Query(...),
|
||||
@@ -209,7 +209,7 @@ async def set_col_mapping(
|
||||
|
||||
|
||||
@router.delete("/col-mapping")
|
||||
async def delete_col_mapping(
|
||||
def delete_col_mapping(
|
||||
owner: str = Query(...),
|
||||
repo: str = Query(...),
|
||||
column: str = Query(...),
|
||||
@@ -234,7 +234,7 @@ async def delete_col_mapping(
|
||||
|
||||
|
||||
@router.get("/board-config/{owner}/{repo}")
|
||||
async def get_board_config(owner: str, repo: str):
|
||||
def get_board_config(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
@@ -255,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
|
||||
|
||||
|
||||
@router.post("/board-config/{owner}/{repo}")
|
||||
async def update_board_config(
|
||||
def update_board_config(
|
||||
owner: str,
|
||||
repo: str,
|
||||
columns: str = Query(default=""),
|
||||
@@ -460,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
# ── v0.5.0: Checklists ──
|
||||
|
||||
@router.post("/checklists/{owner}/{repo}/{issue_id}")
|
||||
async def create_checklist(
|
||||
def create_checklist(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -484,7 +484,7 @@ async def create_checklist(
|
||||
|
||||
|
||||
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
|
||||
async def add_checklist_item(
|
||||
def add_checklist_item(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -502,7 +502,7 @@ async def add_checklist_item(
|
||||
|
||||
|
||||
@router.patch("/checklist-items/{item_id}")
|
||||
async def toggle_checklist_item(
|
||||
def toggle_checklist_item(
|
||||
item_id: int,
|
||||
checked: bool = Query(default=False),
|
||||
content: str = Query(default=""),
|
||||
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
|
||||
|
||||
|
||||
@router.delete("/checklist-items/{item_id}")
|
||||
async def delete_checklist_item(item_id: int):
|
||||
def delete_checklist_item(item_id: int):
|
||||
"""Delete a checklist item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
|
||||
@@ -533,7 +533,7 @@ async def delete_checklist_item(item_id: int):
|
||||
|
||||
|
||||
@router.delete("/checklists/{checklist_id}")
|
||||
async def delete_checklist(checklist_id: int):
|
||||
def delete_checklist(checklist_id: int):
|
||||
"""Delete a checklist and all its items."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
|
||||
@@ -545,14 +545,19 @@ async def delete_checklist(checklist_id: int):
|
||||
# ── v1.0.0: User management ──
|
||||
|
||||
@router.get("/users/me")
|
||||
async def get_my_profile(request: Request):
|
||||
def get_my_profile(request: Request):
|
||||
"""Get current user profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
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)
|
||||
@@ -560,7 +565,7 @@ async def get_my_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/users/me")
|
||||
async def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
email: str = Query(default="")):
|
||||
"""Update current user's local profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -579,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
|
||||
# ── v0.5.0: Card priority & due date ──
|
||||
|
||||
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||
async def update_card(
|
||||
def update_card(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -668,7 +673,7 @@ async def capture_frontend_error(request: Request):
|
||||
|
||||
|
||||
@router.get("/frontend-errors")
|
||||
async def get_frontend_errors(request: Request, clear: bool = True):
|
||||
def get_frontend_errors(request: Request, clear: bool = True):
|
||||
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
||||
errors = list(_frontend_errors)
|
||||
if clear:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
"""FlowDeck — Public API v2.
|
||||
|
||||
Découpe A28 : l'ancien `api_v2.py` (2 110 lignes, 115 routes) est devenu
|
||||
ce package — un module par concern (`_common` = helpers), `router`
|
||||
agrégé ci-dessous avec le même prefix/tags qu'avant → 0 changement
|
||||
d'URL, 0 changement d'operation_id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import (
|
||||
admin,
|
||||
collections,
|
||||
engagement,
|
||||
identity,
|
||||
planning,
|
||||
projects,
|
||||
properties,
|
||||
sharing,
|
||||
templates_io,
|
||||
views,
|
||||
webhooks,
|
||||
workspaces,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v2")
|
||||
for _mod in (
|
||||
identity,
|
||||
workspaces,
|
||||
collections,
|
||||
properties,
|
||||
views,
|
||||
engagement,
|
||||
sharing,
|
||||
planning,
|
||||
templates_io,
|
||||
projects,
|
||||
admin,
|
||||
webhooks,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""FlowDeck — API v2 : helpers partagés des modules de routes (A28)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def _v2_rate_check(request: Request, user: dict) -> None:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
th = user.get("_token_hash")
|
||||
if not check_v2_rate_limit(th, ip):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded: 300 req/min per token")
|
||||
|
||||
# ── Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""FlowDeck — Public API v2 : admin.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.patch("/admin/users/{uid}")
|
||||
def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone():
|
||||
raise HTTPException(404, "User not found")
|
||||
sets = []
|
||||
params: list = []
|
||||
for k in ("is_active", "is_admin", "full_name", "email"):
|
||||
if k in body:
|
||||
sets.append(f"{k}=?")
|
||||
params.append(int(body[k]) if k in ("is_active", "is_admin") else body[k])
|
||||
if not sets:
|
||||
raise HTTPException(400, "No fields")
|
||||
params.append(uid)
|
||||
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "admin.user_update", "user", uid, "", request)
|
||||
return {"id": uid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/admin/users/{uid}")
|
||||
def admin_delete_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
if uid == user["id"]:
|
||||
raise HTTPException(400, "Cannot delete yourself")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
conn.commit()
|
||||
audit_log(user, "admin.user_delete", "user", uid, "", request)
|
||||
return {"id": uid, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/admin/audit-logs")
|
||||
def admin_audit_logs_v2(request: Request, limit: int = 50, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
limit = max(1, min(limit, 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
||||
return {"logs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/webhooks/events")
|
||||
def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Catalogue of deliverable events (+ wildcard syntax)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]}
|
||||
@@ -0,0 +1,566 @@
|
||||
"""FlowDeck — Public API v2 : collections.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
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.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
def list_collections_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws_filter = request.query_params.get("workspace_id")
|
||||
q = (request.query_params.get("query") or "").strip()
|
||||
with get_conn() as conn:
|
||||
where = []
|
||||
params: list = []
|
||||
if ws_filter:
|
||||
try:
|
||||
wid = int(ws_filter)
|
||||
where.append("c.workspace_id=?")
|
||||
params.append(wid)
|
||||
except ValueError:
|
||||
pass
|
||||
if q:
|
||||
where.append("(c.name LIKE ? OR c.description LIKE ?)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like])
|
||||
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM collections c {clause}", params).fetchone()[0]
|
||||
rows = conn.execute(f"SELECT c.* FROM collections c {clause} ORDER BY c.name LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
cols = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
# filter by visibility: skip private not visible (best-effort)
|
||||
cols.append(d)
|
||||
resp = {"collections": cols, "total": total, "limit": limit, "offset": offset}
|
||||
return JSONResponse(content=resp, headers=paginate_headers(total))
|
||||
|
||||
|
||||
@router.post("/collections")
|
||||
def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
workspace_id = body.get("workspace_id")
|
||||
schema = body.get("schema") or body.get("schema_json") or []
|
||||
if isinstance(schema, str):
|
||||
try:
|
||||
schema = json.loads(schema)
|
||||
except Exception:
|
||||
schema = []
|
||||
schema_json = json.dumps(schema)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# materialize properties if schema provided — A25 : PAS de try ici,
|
||||
# une exception doit interrompre la transaction (sinon la collection est
|
||||
# commitée sans son schéma et l'erreur disparaît).
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
# default view
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
audit_log(user, "collection.create", "collection", cid, name, request)
|
||||
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
def get_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
pages = conn.execute("SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT 50", (collection_id,)).fetchall()
|
||||
d = row_to_dict(row)
|
||||
d["pages"] = [row_to_dict(p) for p in pages]
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/collections/{collection_id}")
|
||||
def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
name = body.get("name", row["name"])
|
||||
description = body.get("description", row["description"])
|
||||
icon = body.get("icon", row["icon"])
|
||||
schema = body.get("schema") or body.get("schema_json")
|
||||
if schema is not None:
|
||||
sj = json.dumps(schema) if isinstance(schema, (list, dict)) else str(schema)
|
||||
else:
|
||||
sj = row["schema_json"]
|
||||
conn.execute("UPDATE collections SET name=?, description=?, icon=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, description, icon, sj, collection_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.update", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/linked")
|
||||
def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip() or f"Linked DB {collection_id}"
|
||||
with get_conn() as conn:
|
||||
src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, src["description"], src["icon"], src["schema_json"], src["workspace_id"] if "workspace_id" in src.keys() else None, user["id"]))
|
||||
nid = cur.lastrowid
|
||||
# copy data source as linked
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
# copy views + properties (light)
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for p in rows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for v in vrows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
conn.commit()
|
||||
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/task")
|
||||
def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur_val = row["is_task"] if "is_task" in row.keys() else 0
|
||||
new_val = 0 if cur_val else 1
|
||||
conn.execute("UPDATE collections SET is_task=? WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.toggle_task", "collection", collection_id, str(new_val), request)
|
||||
return {"id": collection_id, "is_task": bool(new_val)}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sources")
|
||||
def list_sources(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
rows = conn.execute("SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"sources": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sources")
|
||||
def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
src_id = body.get("source_collection_id") or body.get("source_id")
|
||||
if not src_id:
|
||||
raise HTTPException(400, "source_collection_id required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (src_id,)).fetchone():
|
||||
raise HTTPException(404, "Source collection not found")
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id) VALUES (?, ?)", (collection_id, src_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
audit_log(user, "collection.add_source", "collection", collection_id, str(src_id), request)
|
||||
return {"collection_id": collection_id, "source_collection_id": src_id, "status": "added"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sources/{source_id}")
|
||||
def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
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()
|
||||
audit_log(user, "collection.remove_source", "collection", collection_id, str(source_id), request)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages")
|
||||
def list_collection_pages_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
total = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# filters: filter[status]=Done etc., sort, fields
|
||||
# Simple: filter by property name via property_values_json LIKE (best-effort), sort by position or title
|
||||
sort = request.query_params.get("sort") or ""
|
||||
order = "position"
|
||||
desc = False
|
||||
if sort:
|
||||
if sort.startswith("-"):
|
||||
desc = True
|
||||
sort = sort[1:]
|
||||
# allow sorting by title/position/created_at
|
||||
if sort in ("title", "position", "created_at", "updated_at"):
|
||||
order = sort
|
||||
direction = "DESC" if desc else "ASC"
|
||||
rows = conn.execute(f"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY {order} {direction} LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
|
||||
# apply filter[xxx] in-memory (small)
|
||||
filters = {k[7:-1]: v for k, v in request.query_params.items() if k.startswith("filter[") and k.endswith("]")}
|
||||
fields = request.query_params.get("fields")
|
||||
fields_set = set(fields.split(",")) if fields else None
|
||||
out = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
# property filter (AND)
|
||||
if filters:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}") if isinstance(r["property_values_json"], str) else r["property_values_json"]
|
||||
except Exception:
|
||||
pv = {}
|
||||
ok = True
|
||||
for fk, fv in filters.items():
|
||||
# lookup by prop id or name
|
||||
found = False
|
||||
for kk, vv in (pv or {}).items():
|
||||
if str(kk) == str(fk) or str(kk).lower() == fk.lower():
|
||||
if str(vv) == str(fv):
|
||||
found = True
|
||||
break
|
||||
# also check title if filter field is title
|
||||
if fk == "title" and d.get("title") == fv:
|
||||
found = True
|
||||
if not found:
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
continue
|
||||
if fields_set:
|
||||
d = {k: v for k, v in d.items() if k in fields_set or k in ("id", "collection_id")}
|
||||
out.append(d)
|
||||
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/pages")
|
||||
def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
|
||||
icon = body.get("icon", "file")
|
||||
parent_id = body.get("parent_id")
|
||||
prop_vals = body.get("property_values") or body.get("properties") or body.get("property_values_json") or {}
|
||||
if isinstance(prop_vals, str):
|
||||
try:
|
||||
prop_vals = json.loads(prop_vals)
|
||||
except Exception:
|
||||
prop_vals = {}
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
# validate properties if helper exists
|
||||
try:
|
||||
pass
|
||||
# light validation: we rely on existing validators
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# apply auto props
|
||||
try:
|
||||
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()]
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, prop_vals, user, is_create=True)
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
audit_log(user, "page.create", "collection_page", pid, title, request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}")
|
||||
def get_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
# also try pages table (block pages)
|
||||
row2 = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
d = row_to_dict(row2)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content).
|
||||
if (d.get("content_format") or "blocks") == "blocks" and d.get("content"):
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
d["content"] = resolve_content_json(d["content"], d["content_format"])
|
||||
return d
|
||||
d = row_to_dict(row)
|
||||
# property_values_json already parsed by row_to_dict
|
||||
# v6.5.0: expose the row's content page when it exists (no lazy
|
||||
# creation on a read-only endpoint).
|
||||
content_page_id = conn.execute(
|
||||
"SELECT id FROM pages WHERE collection_row_id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
d["content_page_id"] = content_page_id["id"] if content_page_id else None
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
title = body.get("title", row["title"])
|
||||
icon = body.get("icon", row["icon"])
|
||||
pos = body.get("position", row["position"])
|
||||
parent_id = body.get("parent_id", row["parent_id"])
|
||||
pv_raw = row["property_values_json"] or "{}"
|
||||
try:
|
||||
stored = json.loads(pv_raw) if isinstance(pv_raw, str) else dict(pv_raw)
|
||||
except Exception:
|
||||
stored = {}
|
||||
incoming = body.get("property_values") or body.get("properties")
|
||||
if incoming is not None:
|
||||
if isinstance(incoming, str):
|
||||
try:
|
||||
incoming = json.loads(incoming)
|
||||
except Exception:
|
||||
incoming = {}
|
||||
# merge
|
||||
for k, v in (incoming or {}).items():
|
||||
stored[str(k)] = v
|
||||
# apply auto props
|
||||
try:
|
||||
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (row["collection_id"],)).fetchall()]
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, stored, user, is_create=False)
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.update", "collection_page", page_id, "", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.delete", "collection_page", page_id, "", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
|
||||
except Exception:
|
||||
logger.exception("delete_page_v2")
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/restore")
|
||||
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
# 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()
|
||||
if row and row["deleted_at"]:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page_v2")
|
||||
return {"id": page_id, "status": "restored"}
|
||||
raise HTTPException(404, "Page not found or not deleted")
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/move")
|
||||
def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
parent_id = body.get("parent_id", row["parent_id"])
|
||||
position = body.get("position", row["position"])
|
||||
conn.execute("UPDATE collection_pages SET parent_id=?, position=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (parent_id, position, page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.move", "collection_page", page_id, f"parent={parent_id} pos={position}", request)
|
||||
return {"id": page_id, "status": "moved"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/sub-items")
|
||||
def list_sub_items_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute("SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,)).fetchall()
|
||||
return {"sub_items": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/sub-items")
|
||||
def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(404, "Page not found")
|
||||
title = (body.get("title") or "Untitled").strip()
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE parent_id=?", (page_id,)).fetchone()[0]
|
||||
pv = json.dumps(body.get("property_values") or {})
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (parent["collection_id"], title, page_id, max_pos, pv))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "page.create_subitem", "collection_page", nid, title, request)
|
||||
return {"id": nid, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/dependencies")
|
||||
def list_dependencies_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (page_id,)).fetchall()
|
||||
return {"dependencies": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/dependencies")
|
||||
def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
dep_id = body.get("dependency_id") or body.get("depends_on")
|
||||
dtype = body.get("dependency_type") or "blocks"
|
||||
if not dep_id:
|
||||
raise HTTPException(400, "dependency_id required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (dep_id,)).fetchone():
|
||||
raise HTTPException(404, "Dependency page not found")
|
||||
try:
|
||||
conn.execute("INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?, ?, ?)", (page_id, dep_id, dtype))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
audit_log(user, "page.add_dependency", "collection_page", page_id, str(dep_id), request)
|
||||
return {"status": "added"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
|
||||
def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.remove_dependency", "collection_page", page_id, str(dep_id), request)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties")
|
||||
def list_properties_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"properties": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""FlowDeck — Public API v2 : engagement.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
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.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0]
|
||||
rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall()
|
||||
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
text = (body.get("body") or body.get("content") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body is required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end")))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
|
||||
audit_log(user, "comment.create", "comment", nid, text[:80], request)
|
||||
try:
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("create_comment_v2")
|
||||
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
|
||||
|
||||
|
||||
@router.patch("/comments/{comment_id}")
|
||||
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your comment")
|
||||
body_text = body.get("body", row["body"])
|
||||
resolved = body.get("resolved", row["resolved"])
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id))
|
||||
conn.commit()
|
||||
if int(bool(resolved)) and not was_resolved:
|
||||
try:
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("patch_comment_v2")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your comment")
|
||||
conn.execute("DELETE FROM comments WHERE id=?", (comment_id,))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
targets = body.get("user_ids") or body.get("mentions") or []
|
||||
if isinstance(targets, int):
|
||||
targets = [targets]
|
||||
if not targets:
|
||||
raise HTTPException(400, "user_ids required")
|
||||
created = 0
|
||||
with get_conn() as conn:
|
||||
for uid in targets:
|
||||
try:
|
||||
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
|
||||
created += 1
|
||||
except Exception:
|
||||
logger.exception("create_mention_v2")
|
||||
conn.commit()
|
||||
if created:
|
||||
try:
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
|
||||
except Exception:
|
||||
logger.exception("create_mention_v2")
|
||||
return {"mentions": created, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
unread = request.query_params.get("unread")
|
||||
with get_conn() as conn:
|
||||
where = "user_id=?"
|
||||
params: list = [user["id"]]
|
||||
if unread == "1":
|
||||
where += " AND is_read=0"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0]
|
||||
rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/notifications/unread-count")
|
||||
def unread_count(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0]
|
||||
return {"unread": cnt}
|
||||
|
||||
|
||||
@router.post("/notifications/{notif_id}/read")
|
||||
def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"]))
|
||||
conn.commit()
|
||||
return {"id": notif_id, "status": "read"}
|
||||
|
||||
|
||||
@router.post("/notifications/read-all")
|
||||
def mark_all_read(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "all read"}
|
||||
|
||||
|
||||
@router.patch("/users/me/preferences")
|
||||
def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
try:
|
||||
cur = json.loads(row["notification_prefs"] or "{}")
|
||||
except Exception:
|
||||
cur = {}
|
||||
cur.update(body)
|
||||
conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"]))
|
||||
conn.commit()
|
||||
return {"preferences": cur}
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall()
|
||||
return {"favorites": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
try:
|
||||
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite_v2")
|
||||
return {"page_id": pid, "status": "added"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{page_id}")
|
||||
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite_v2")
|
||||
return {"page_id": page_id, "status": "removed"}
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def list_tags_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (request.query_params.get("q") or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/tags")
|
||||
def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name required")
|
||||
color = body.get("color", "#787774")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"]))
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"id": tid, "name": name, "color": color, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/tags/{tag_id}")
|
||||
def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Tag not found")
|
||||
name = body.get("name", row["name"])
|
||||
color = body.get("color", row["color"])
|
||||
conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id))
|
||||
conn.commit()
|
||||
return {"id": tag_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
|
||||
conn.commit()
|
||||
return {"id": tag_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/tags")
|
||||
def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
tag_id = body.get("tag_id")
|
||||
if not tag_id:
|
||||
raise HTTPException(400, "tag_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"page_id": page_id, "tag_id": tag_id, "status": "attached"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/tags/{tag_id}")
|
||||
def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
|
||||
conn.commit()
|
||||
return {"status": "detached"}
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
def list_recents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit = int(request.query_params.get("limit", "20"))
|
||||
st = request.query_params.get("source_type")
|
||||
with get_conn() as conn:
|
||||
if st:
|
||||
rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall()
|
||||
return {"recents": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall()
|
||||
return {"shares": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""FlowDeck — Public API v2 : identity.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _hash, _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "API token").strip()[:100]
|
||||
scopes = validate_scopes_input(body.get("scopes") or "read,write")
|
||||
expires_at = body.get("expires_at")
|
||||
# Idempotency
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
token = f"fd_{secrets.token_urlsafe(32)}"
|
||||
prefix = token[:12]
|
||||
th = _hash(token)
|
||||
exp_val = None
|
||||
if expires_at:
|
||||
try:
|
||||
# accept ISO string
|
||||
exp_val = str(expires_at)
|
||||
# validate parse
|
||||
datetime.fromisoformat(exp_val.replace("Z", "+00:00"))
|
||||
except Exception as err:
|
||||
raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(user["id"], name, th, prefix, scopes, exp_val),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Token creation failed: {e}") from None
|
||||
row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request)
|
||||
data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 200)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
def list_tokens(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["created_at"] = to_iso8601(d.get("created_at"))
|
||||
d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None
|
||||
d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None
|
||||
# never expose hash
|
||||
out.append({k: v for k, v in d.items() if k != "token_hash"})
|
||||
return {"tokens": out}
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}")
|
||||
def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your token")
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "token.revoke", "api_token", token_id, "", request)
|
||||
return {"id": token_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/rotate")
|
||||
def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
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:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your token")
|
||||
# revoke old
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
new_token = f"fd_{secrets.token_urlsafe(32)}"
|
||||
th = _hash(new_token)
|
||||
prefix = new_token[:12]
|
||||
cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write"))
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request)
|
||||
return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"}
|
||||
|
||||
|
||||
@router.get("/users/me")
|
||||
def get_me(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
d = row_to_dict(row)
|
||||
# parse json prefs
|
||||
for k in ("notification_prefs", "sidebar_config"):
|
||||
if isinstance(d.get(k), str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}")
|
||||
except Exception:
|
||||
logger.exception("get_me")
|
||||
# never expose secrets
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/users/me")
|
||||
def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
|
||||
updates = {}
|
||||
for k in allowed:
|
||||
if k in body:
|
||||
updates[k] = body[k]
|
||||
if not updates:
|
||||
raise HTTPException(400, "No updatable fields")
|
||||
# validation
|
||||
if "email" in updates and updates["email"] and "@" not in str(updates["email"]):
|
||||
raise HTTPException(400, "Invalid email")
|
||||
with get_conn() as conn:
|
||||
sets = []
|
||||
params = []
|
||||
for k, v in updates.items():
|
||||
if k in ("notification_prefs", "sidebar_config"):
|
||||
v = json.dumps(v) if isinstance(v, (dict, list)) else str(v)
|
||||
sets.append(f"{k}=?")
|
||||
params.append(v)
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
audit_log(user, "user.update", "user", user["id"], "", request)
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (q or request.query_params.get("q") or "").strip()
|
||||
if not q:
|
||||
return {"users": []}
|
||||
like = f"%{q}%"
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall()
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""FlowDeck — Public API v2 : planning.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
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.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Sprint").strip()
|
||||
start = body.get("start_date") or body.get("start") or ""
|
||||
end = body.get("end_date") or body.get("end") or ""
|
||||
if not start or not end:
|
||||
raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or ""))
|
||||
sid = cur.lastrowid
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
logger.exception("create_sprint_v2")
|
||||
return {"id": sid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/sprints/{sprint_id}")
|
||||
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
name = body.get("name", row["name"])
|
||||
start = body.get("start_date", row["start_date"])
|
||||
end = body.get("end_date", row["end_date"])
|
||||
goal = body.get("goal", row["goal"])
|
||||
status = body.get("status", row["status"])
|
||||
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
|
||||
except Exception:
|
||||
logger.exception("patch_sprint_v2")
|
||||
return {"id": sprint_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/sprints/{sprint_id}")
|
||||
def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
|
||||
conn.commit()
|
||||
return {"id": sprint_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/sprints/{sprint_id}/assign")
|
||||
def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1)))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
|
||||
def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/sprints/{sprint_id}/burndown")
|
||||
def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not s:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall()
|
||||
total = len(pages)
|
||||
# crude: completed where status property == Done (best-effort)
|
||||
completed = 0
|
||||
for p in pages:
|
||||
try:
|
||||
pv = json.loads(p["property_values_json"] or "{}")
|
||||
for v in pv.values():
|
||||
if str(v).lower() in ("done", "completed", "terminé"):
|
||||
completed += 1
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("burndown_v2")
|
||||
remaining = total - completed
|
||||
# ideal linear
|
||||
ideal = [round(total * (1 - i / 10)) for i in range(11)]
|
||||
return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates")
|
||||
def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
||||
return {"templates": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""FlowDeck — Public API v2 : projects.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def list_projects_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall()
|
||||
return {"projects": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)):
|
||||
get_bearer_user(request, authorization)
|
||||
# proxy to gitea client? Return placeholder listing from projects table
|
||||
with get_conn() as conn:
|
||||
proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone()
|
||||
if not proj:
|
||||
raise HTTPException(404, "Project not found")
|
||||
# delegate to gitea API if available (best-effort)
|
||||
try:
|
||||
from app.services.gitea_client import gitea
|
||||
tree = await gitea.list_repo_files(owner, repo, path or "")
|
||||
return {"owner": owner, "repo": repo, "path": path, "tree": tree}
|
||||
except Exception:
|
||||
return {"owner": owner, "repo": repo, "path": path, "tree": []}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (query or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
return {"results": [], "query": q}
|
||||
like = f"%{q}%"
|
||||
with get_conn() as conn:
|
||||
pages = []
|
||||
# try FTS5
|
||||
try:
|
||||
rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '<mark>', '</mark>', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall()
|
||||
pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows]
|
||||
except Exception:
|
||||
rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall()
|
||||
pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows]
|
||||
# collections
|
||||
colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall()
|
||||
results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls]
|
||||
return {"query": q, "results": results}
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
limit = max(1, min(limit, 100))
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall()
|
||||
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""FlowDeck — Public API v2 : properties.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties")
|
||||
def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
ptype = body.get("prop_type") or body.get("type") or "text"
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
try:
|
||||
cur = conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, position, required, visible_in_views, validation_json, group_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (collection_id, name, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip()))
|
||||
conn.commit()
|
||||
pid = cur.lastrowid
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property exists: {e}") from None
|
||||
audit_log(user, "property.create", "property", pid, name, request)
|
||||
return {"id": pid, "name": name, "prop_type": ptype, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/properties/{prop_id}")
|
||||
def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
name = body.get("name", row["name"])
|
||||
opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]")))
|
||||
nf = body.get("number_format", row["number_format"])
|
||||
req = int(bool(body.get("required", row["required"])))
|
||||
vis = int(bool(body.get("visible_in_views", row["visible_in_views"])))
|
||||
vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}")
|
||||
grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "")
|
||||
conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id))
|
||||
conn.commit()
|
||||
audit_log(user, "property.update", "property", prop_id, "", request)
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}")
|
||||
def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
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")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "property.delete", "property", prop_id, "", request)
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/properties/{prop_id}/relation")
|
||||
def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
related_id = body.get("related_collection_id")
|
||||
reverse = (body.get("reverse_name") or "").strip()
|
||||
if not related_id:
|
||||
raise HTTPException(400, "related_collection_id required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id))
|
||||
if reverse:
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0]
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
|
||||
except Exception:
|
||||
logger.exception("create_relation_v2")
|
||||
conn.commit()
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.post("/properties/evaluate-formula")
|
||||
def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
expr = body.get("expression") or body.get("formula")
|
||||
if not expr:
|
||||
raise HTTPException(400, "expression required")
|
||||
ctx = body.get("context") or {}
|
||||
try:
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
res = FormulaEngine().evaluate(expr, ctx)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Formula error: {e}") from None
|
||||
return {"result": res, "expression": expr}
|
||||
|
||||
|
||||
@router.post("/properties/compute-rollup")
|
||||
def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"):
|
||||
if k not in body:
|
||||
raise HTTPException(400, f"{k} required")
|
||||
try:
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count"))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Rollup error: {e}") from None
|
||||
return {"result": res}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/views")
|
||||
def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"views": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""FlowDeck — Public API v2 : sharing.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
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.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/shares")
|
||||
def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
perm = (body.get("permission") or "view").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
|
||||
email = (body.get("email") or "").strip()
|
||||
uid = body.get("user_id")
|
||||
if not email and not uid:
|
||||
raise HTTPException(400, "email or user_id required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"]))
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
|
||||
except Exception:
|
||||
logger.exception("create_share_v2")
|
||||
return {"id": nid, "page_id": page_id, "status": "shared"}
|
||||
|
||||
|
||||
@router.patch("/shares/{share_id}")
|
||||
def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
perm = (body.get("permission") or "").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone():
|
||||
raise HTTPException(404, "Share not found")
|
||||
conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id))
|
||||
conn.commit()
|
||||
return {"id": share_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/shares/{share_id}")
|
||||
def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share not found")
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
# unset is_shared if no shares left
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0]
|
||||
if cnt == 0:
|
||||
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],))
|
||||
conn.commit()
|
||||
return {"id": share_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
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)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
def list_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall()
|
||||
# also page_versions for block pages
|
||||
vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall()
|
||||
return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history/restore")
|
||||
def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
hid = body.get("history_id") or body.get("id") or body.get("version_id")
|
||||
if not hid:
|
||||
raise HTTPException(400, "history_id required")
|
||||
with get_conn() as conn:
|
||||
h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
|
||||
if h:
|
||||
conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id))
|
||||
conn.commit()
|
||||
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
|
||||
h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
|
||||
if h2:
|
||||
try:
|
||||
snap = json.loads(h2["snapshot_json"] or "{}")
|
||||
except Exception:
|
||||
snap = {}
|
||||
# best-effort restore content
|
||||
if snap.get("content"):
|
||||
conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id))
|
||||
conn.commit()
|
||||
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
|
||||
raise HTTPException(404, "History not found")
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
def list_sprints_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM sprints WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
|
||||
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""FlowDeck — Public API v2 : templates_io.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates")
|
||||
def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Template").strip()
|
||||
pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {})
|
||||
cj = json.dumps(body.get("content") or body.get("content_json") or [])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj))
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": tid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
name = body.get("name", row["name"])
|
||||
pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"]
|
||||
cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"]
|
||||
conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id))
|
||||
conn.commit()
|
||||
return {"id": template_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
|
||||
conn.commit()
|
||||
return {"id": template_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/apply")
|
||||
def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0]
|
||||
pv = tpl["property_values_json"] or "{}"
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"template_id": template_id, "page_id": pid, "status": "applied"}
|
||||
|
||||
|
||||
@router.get("/templates/database")
|
||||
def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database/{template_id}/apply")
|
||||
def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
name = (body.get("name") or tpl["name"]).strip()
|
||||
schema = json.loads(tpl["schema_json"] or "[]")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# A25 : pas de try — un échec de matérialisation doit interrompre la
|
||||
# transaction plutôt que de commiter une collection sans schéma.
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/export")
|
||||
def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
fmt = (format or request.query_params.get("format") or "markdown").lower()
|
||||
if fmt not in ("markdown", "html", "pdf"):
|
||||
raise HTTPException(400, "format must be markdown, html or pdf")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
# try collection_pages
|
||||
row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
# collection pages: return JSON
|
||||
return {"page": row_to_dict(row2), "format": fmt}
|
||||
# block pages: delegate to export service
|
||||
from app.services.export import export_page as _export
|
||||
try:
|
||||
data, mime, fname = _export(row, fmt) # type: ignore
|
||||
return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Export failed: {e}") from None
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
import csv
|
||||
import io
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()]
|
||||
rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
out = io.StringIO()
|
||||
writer = csv.writer(out)
|
||||
header = ["Title"] + [p["name"] for p in props]
|
||||
writer.writerow(header)
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
vals = [r["title"]]
|
||||
for p in props:
|
||||
vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or ""))
|
||||
writer.writerow(vals)
|
||||
return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'})
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
try:
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
data = await file.read() if file else b""
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
except Exception as err:
|
||||
raise HTTPException(400, "file required (multipart)") from err
|
||||
import csv
|
||||
import io
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
created = 0
|
||||
with get_conn() as conn:
|
||||
for row in reader:
|
||||
title = row.get("Title") or row.get("title") or "Untitled"
|
||||
# map remaining columns to property names
|
||||
pv = {}
|
||||
# resolve prop name -> id
|
||||
props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()}
|
||||
for k, v in row.items():
|
||||
if k in ("Title", "title"):
|
||||
continue
|
||||
pid = props.get(k)
|
||||
if pid:
|
||||
pv[str(pid)] = v
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv)))
|
||||
created += 1
|
||||
conn.commit()
|
||||
return {"imported": created, "status": "ok"}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — Public API v2 : views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
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.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/views")
|
||||
def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "New View").strip()
|
||||
vtype = body.get("view_type") or body.get("type") or "table"
|
||||
config = body.get("config") or body.get("config_json") or {}
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"]))
|
||||
vid = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "view.create", "view", vid, name, request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
|
||||
except Exception:
|
||||
logger.exception("create_view_v2")
|
||||
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/views/{view_id}")
|
||||
def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "View not found")
|
||||
cfg = json.loads(row["config_json"] or "{}")
|
||||
if "config" in body:
|
||||
cfg.update(body["config"])
|
||||
elif "config_json" in body:
|
||||
try:
|
||||
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
|
||||
except Exception:
|
||||
logger.exception("patch_view_v2")
|
||||
# also flat keys
|
||||
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
|
||||
if k in body:
|
||||
cfg[k] = body[k]
|
||||
name = body.get("name", row["name"])
|
||||
vtype = body.get("view_type") or body.get("type") or row["view_type"]
|
||||
conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id))
|
||||
conn.commit()
|
||||
audit_log(user, "view.update", "view", view_id, "", request)
|
||||
return {"id": view_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}")
|
||||
def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
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")
|
||||
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "view.delete", "view", view_id, "", request)
|
||||
return {"id": view_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/save-as")
|
||||
def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip() or "Copy"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "View not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0]
|
||||
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"]))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
def list_dashboards_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
||||
return {"dashboards": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Dashboard").strip()
|
||||
layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []}
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout)))
|
||||
did = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": did, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/dashboards/{dashboard_id}")
|
||||
def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
name = body.get("name", row["name"])
|
||||
layout = body.get("layout") or body.get("layout_json")
|
||||
if layout is not None:
|
||||
layout_json = json.dumps(layout)
|
||||
else:
|
||||
layout_json = row["layout_json"]
|
||||
conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id))
|
||||
conn.commit()
|
||||
return {"id": dashboard_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/dashboards/{dashboard_id}")
|
||||
def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
|
||||
conn.commit()
|
||||
return {"id": dashboard_id, "status": "deleted"}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""FlowDeck — Public API v2 : webhooks.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"webhooks": [dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS, _event_matches
|
||||
url = (body.get("url") or "").strip()
|
||||
event = (body.get("event") or "page.created").strip()
|
||||
secret = (body.get("secret") or "").strip()
|
||||
if not url or not url.startswith("http"):
|
||||
raise HTTPException(400, "url must start with http")
|
||||
if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))):
|
||||
raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events")
|
||||
# make sure the pattern matches at least one known event
|
||||
if not any(_event_matches(event, e) for e in EVENTS):
|
||||
raise HTTPException(400, f"Event pattern '{event}' matches no known event")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
|
||||
wid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "webhook.create", "webhook", wid, url, request)
|
||||
return {"id": wid, "status": "created", "webhook": dict(row) if row else {},
|
||||
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
|
||||
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
url = body.get("url", row["url"])
|
||||
event = body.get("event", row["event"])
|
||||
secret = body.get("secret", row["secret"])
|
||||
active = int(bool(body.get("active", row["active"])))
|
||||
conn.execute("UPDATE webhook_subscriptions SET url=?, event=?, secret=?, active=? WHERE id=?", (url, event, secret, active, webhook_id))
|
||||
conn.commit()
|
||||
audit_log(user, "webhook.update", "webhook", webhook_id, "", request)
|
||||
return {"id": webhook_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "webhook.delete", "webhook", webhook_id, "", request)
|
||||
return {"id": webhook_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
# live delivery via the prod dispatcher (HMAC + retry + journal),
|
||||
# direct to this subscription only (no wildcard fan-out)
|
||||
from app.services.webhook_outbound import deliver_to_sub
|
||||
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
|
||||
{"webhook_id": webhook_id, "test": True},
|
||||
row["secret"] or "")
|
||||
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
|
||||
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
|
||||
|
||||
|
||||
@router.get("/webhooks/{webhook_id}/deliveries")
|
||||
def list_deliveries_v2(webhook_id: int, request: Request,
|
||||
status: str | None = None,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
if status:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?",
|
||||
(webhook_id, status)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, status, limit, offset)).fetchall()
|
||||
else:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?",
|
||||
(webhook_id,)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, limit, offset)).fetchall()
|
||||
return JSONResponse(
|
||||
content={"deliveries": [row_to_dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/retry")
|
||||
async def retry_webhook_deliveries(webhook_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Manually retry failed deliveries for a webhook."""
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
|
||||
from app.services.webhook_outbound import retry_due_deliveries
|
||||
|
||||
with get_conn() as conn:
|
||||
# Force retry by setting next_retry_at to the past
|
||||
conn.execute(
|
||||
"""UPDATE webhook_deliveries
|
||||
SET next_retry_at = strftime('%s', 'now', '-1 second')
|
||||
WHERE webhook_id = ? AND status = 'retrying'""",
|
||||
(webhook_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
retried = await retry_due_deliveries()
|
||||
audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request)
|
||||
return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried}
|
||||
|
||||
|
||||
@router.post("/webhooks/verify-signature")
|
||||
def verify_webhook_signature(request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
"""Verify a webhook signature (for debugging/testing)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
|
||||
from app.services.webhook_outbound import verify_signature
|
||||
|
||||
secret = body.get("secret", "")
|
||||
payload = body.get("payload", "{}")
|
||||
signature = body.get("signature", "")
|
||||
|
||||
is_valid = verify_signature(secret, payload.encode(), signature)
|
||||
|
||||
audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request)
|
||||
return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""FlowDeck — Public API v2 : workspaces.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces")
|
||||
def list_workspaces(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0]
|
||||
rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["created_at"] = to_iso8601(d.get("created_at"))
|
||||
try:
|
||||
d["settings"] = json.loads(d.get("settings_json") or "{}")
|
||||
except Exception:
|
||||
d["settings"] = {}
|
||||
out.append(d)
|
||||
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/workspaces")
|
||||
def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {})
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json))
|
||||
wid = cur.lastrowid
|
||||
# owner is implicitly admin member
|
||||
try:
|
||||
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
|
||||
except Exception:
|
||||
logger.exception("create_workspace")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "workspace.create", "workspace", wid, name, request)
|
||||
data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 200)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}")
|
||||
def get_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
# ACL: must be member or owner
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
is_owner = row["owner_id"] == user["id"]
|
||||
if not is_owner and not member and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
||||
d = row_to_dict(row)
|
||||
d["members"] = [dict(m) for m in members]
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}")
|
||||
def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
# check admin member
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can edit workspace")
|
||||
name = body.get("name", row["name"])
|
||||
sj = body.get("settings_json") or body.get("settings")
|
||||
if sj is not None:
|
||||
sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj)
|
||||
else:
|
||||
sj = row["settings_json"]
|
||||
conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.update", "workspace", workspace_id, "", request)
|
||||
return {"id": workspace_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}")
|
||||
def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only owner can delete workspace")
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.delete", "workspace", workspace_id, "", request)
|
||||
return {"id": workspace_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
def list_workspace_members(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
||||
return {"members": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/members")
|
||||
def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
target_id = body.get("user_id") or body.get("uid")
|
||||
email = (body.get("email") or "").strip()
|
||||
role = (body.get("role") or "editor").strip().lower()
|
||||
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
||||
raise HTTPException(400, "Invalid role")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
# only owner/admin can invite
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can invite")
|
||||
uid = target_id
|
||||
if not uid and email:
|
||||
u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()
|
||||
if not u:
|
||||
raise HTTPException(404, f"User with email {email} not found")
|
||||
uid = u["id"]
|
||||
if not uid:
|
||||
raise HTTPException(400, "user_id or email required")
|
||||
try:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role))
|
||||
except Exception:
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{uid}")
|
||||
def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
role = (body.get("role") or "").strip().lower()
|
||||
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
||||
raise HTTPException(400, "Invalid role")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can change roles")
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
||||
if conn.total_changes == 0:
|
||||
raise HTTPException(404, "Member not found")
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{uid}")
|
||||
def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can remove members")
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"}
|
||||
+13
-13
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
|
||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
@@ -157,7 +157,7 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
@@ -195,7 +195,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
||||
@@ -209,7 +209,7 @@ async def delete_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||
|
||||
@router.get("/agents/conversations")
|
||||
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
@@ -264,7 +264,7 @@ async def create_conversation_v2(request: Request, authorization: str | None = H
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}")
|
||||
async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
def get_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -279,7 +279,7 @@ async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
|
||||
|
||||
@router.delete("/agents/conversations/{conversation_id}")
|
||||
async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
@@ -292,7 +292,7 @@ async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}/actions")
|
||||
async def list_actions_v2(conversation_id: int, request: Request,
|
||||
def list_actions_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -306,7 +306,7 @@ async def list_actions_v2(conversation_id: int, request: Request,
|
||||
|
||||
|
||||
@router.post("/agents/actions/{action_id}/undo")
|
||||
async def undo_action_v2(action_id: int, request: Request,
|
||||
def undo_action_v2(action_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
|
||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
@@ -535,7 +535,7 @@ async def create_skill_v2(request: Request, authorization: str | None = Header(d
|
||||
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
@@ -596,7 +596,7 @@ async def import_skill_v2(request: Request, authorization: str | None = Header(d
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}")
|
||||
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
@@ -606,7 +606,7 @@ async def get_skill_v2(skill_id: int, request: Request, authorization: str | Non
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill_v2(skill_id: int, request: Request,
|
||||
def export_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||
_guard(request, authorization)
|
||||
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill_v2(skill_id: int, request: Request,
|
||||
def delete_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -99,7 +99,7 @@ def _query(source: str, actor: str, action: str, limit: int, offset: int):
|
||||
|
||||
|
||||
@router.get("/api/v2/audit/logs")
|
||||
async def audit_logs(request: Request):
|
||||
def audit_logs(request: Request):
|
||||
_admin_user(request)
|
||||
qp = request.query_params
|
||||
source = (qp.get("source") or "all").lower()
|
||||
|
||||
+28
-34
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}/auth/callback"
|
||||
|
||||
def _with_nonce(html: str) -> str:
|
||||
"""A20 : injecte le nonce CSP au moment du rendu.
|
||||
|
||||
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
|
||||
requête, donc il ne peut être figé qu'ici.
|
||||
"""
|
||||
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
|
||||
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_page(request: Request):
|
||||
def register_page(request: Request):
|
||||
"""Show the registration page (local login page with register tab active)."""
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
|
||||
'class="tab active" onclick="switchTab(\'login\')"',
|
||||
'class="tab" onclick="switchTab(\'login\')"'
|
||||
).replace(
|
||||
@@ -163,16 +173,16 @@ async def register_page(request: Request):
|
||||
).replace(
|
||||
'id="submit-btn">Login<',
|
||||
'id="submit-btn">Register<'
|
||||
), status_code=200)
|
||||
)), status_code=200)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
@@ -210,15 +220,11 @@ async def login(request: Request, provider: str = Query("gitea")):
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
def register(request: Request, body: dict = Body(default={})):
|
||||
"""Register a new local account."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
@@ -267,7 +273,7 @@ async def register(request: Request):
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
def local_login(request: Request, body: dict = Body(default={})):
|
||||
"""Login with email + password."""
|
||||
import time
|
||||
|
||||
@@ -275,10 +281,6 @@ async def local_login(request: Request):
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
@@ -400,7 +402,7 @@ async def callback(
|
||||
oauth_mode = request.session.pop("oauth_mode", "")
|
||||
if oauth_mode == "link":
|
||||
from app.auth.session import get_current_user as gcu
|
||||
current = await gcu(request)
|
||||
current = gcu(request)
|
||||
if not current:
|
||||
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
||||
from app.db import get_conn as _gc
|
||||
@@ -450,7 +452,7 @@ async def callback(
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
def logout(request: Request):
|
||||
"""Clear session and redirect to login page.
|
||||
|
||||
SAML sessions additionally hand over to the IdP's Single Logout when one
|
||||
@@ -470,10 +472,10 @@ async def logout(request: Request):
|
||||
|
||||
|
||||
@router.get("/user")
|
||||
async def current_user(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
@@ -482,16 +484,12 @@ async def current_user(request: Request):
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
def local_verify(request: Request, body: dict = Body(default={})):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
@@ -519,7 +517,7 @@ def _session_user_or_401(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
@@ -527,22 +525,18 @@ async def twofa_status(request: Request):
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
def twofa_activate(request: Request, body: dict = Body(default={})):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
@@ -553,7 +547,7 @@ async def twofa_activate(request: Request):
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
@@ -573,4 +567,4 @@ def _log_login(user_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_log_login")
|
||||
|
||||
+12
-17
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
|
||||
|
||||
|
||||
@router.get("/workspace/automations")
|
||||
async def list_automations(request: Request):
|
||||
def list_automations(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
@@ -79,8 +79,7 @@ async def list_automations(request: Request):
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_automation(request: Request, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user["id"]
|
||||
@@ -109,7 +108,7 @@ async def create_automation(request: Request):
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}")
|
||||
async def get_automation(request: Request, auto_id: int):
|
||||
def get_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -118,8 +117,7 @@ async def get_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}")
|
||||
async def update_automation(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_automation(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
@@ -147,7 +145,7 @@ async def update_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/{auto_id}")
|
||||
async def delete_automation(request: Request, auto_id: int):
|
||||
def delete_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||
conn.commit()
|
||||
@@ -178,7 +176,7 @@ async def run_automation_button(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/runs")
|
||||
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automation_runs WHERE automation_id=?
|
||||
@@ -226,18 +224,17 @@ def _encrypt_step_config(config: dict) -> dict:
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/steps")
|
||||
async def list_steps(request: Request, auto_id: int):
|
||||
def list_steps(request: Request, auto_id: int):
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
def create_step(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
@@ -256,9 +253,8 @@ async def create_step(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
def update_step(request: Request, step_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -279,7 +275,7 @@ async def update_step(request: Request, step_id: int):
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/steps/{step_id}")
|
||||
async def delete_step(request: Request, step_id: int):
|
||||
def delete_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||
@@ -288,12 +284,11 @@ async def delete_step(request: Request, step_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
def set_trigger_mode(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
|
||||
+87
-148
@@ -6,16 +6,19 @@ import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, 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.automations import fire_event, run_event_sync
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.http_client import shared_client
|
||||
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")
|
||||
@@ -64,7 +67,7 @@ def _ensure_block_ids(blocks) -> None:
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
@@ -96,7 +99,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
@@ -122,16 +125,12 @@ async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
async def set_page_lock(request: Request, page_id: int):
|
||||
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
@@ -144,21 +143,17 @@ async def set_page_lock(request: Request, page_id: int):
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]})
|
||||
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]}))
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
async def set_page_options(request: Request, page_id: int):
|
||||
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
@@ -177,7 +172,7 @@ async def set_page_options(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
async def list_page_templates_api(request: Request):
|
||||
def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -197,16 +192,12 @@ async def list_page_templates_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
async def create_page_template(request: Request):
|
||||
def create_page_template(request: Request, body: dict = Body(default={})):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -237,17 +228,13 @@ async def create_page_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
async def use_page_template(request: Request, template_id: int):
|
||||
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
@@ -329,8 +316,8 @@ async def use_page_template(request: Request, template_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name}))
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
@@ -695,7 +682,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
elif ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
@@ -781,7 +768,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
@@ -850,15 +837,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):
|
||||
@@ -906,7 +884,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -950,7 +928,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -962,7 +940,7 @@ async def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -980,14 +958,14 @@ async def add_favorite(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -995,17 +973,16 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
@@ -1018,63 +995,48 @@ 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):
|
||||
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:
|
||||
pass
|
||||
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)
|
||||
run_event_sync(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):
|
||||
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:
|
||||
pass
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
async def list_trash(request: Request):
|
||||
def list_trash(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
async def restore_page(request: Request, page_id: int):
|
||||
def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.restored", {"page_id": page_id})
|
||||
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
async def permanent_delete(request: Request, page_id: int):
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
@@ -1083,7 +1045,7 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
@@ -1094,7 +1056,7 @@ async def trash_page(request: Request):
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
@@ -1102,12 +1064,8 @@ async def list_synced_blocks_api(request: Request, workspace: str = Query(defaul
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
async def create_synced_block_api(request: Request):
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
@@ -1169,7 +1127,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
async def get_synced_block_api(sid: int):
|
||||
def get_synced_block_api(sid: int):
|
||||
"""Get a synced block by id."""
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1179,12 +1137,8 @@ async def get_synced_block_api(sid: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
async def add_synced_to_page(request: Request, page_id: int):
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1195,7 +1149,7 @@ async def add_synced_to_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
@@ -1203,7 +1157,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
async def get_page_synced_refs(request: Request, page_id: int):
|
||||
def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
@@ -1212,7 +1166,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
@@ -1301,12 +1255,12 @@ async def board_view(
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
@router.get("/api/properties/{owner}/{repo}")
|
||||
async def get_properties(owner: str, repo: str):
|
||||
def get_properties(owner: str, repo: str):
|
||||
return {"properties": _get_project_properties(owner, repo)}
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}")
|
||||
async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
prop_type: str = Query(default="select"),
|
||||
options: str = Query(default="")):
|
||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||
@@ -1323,7 +1277,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
|
||||
|
||||
@router.delete("/api/properties/{owner}/{repo}")
|
||||
async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
@@ -1334,7 +1288,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}/values")
|
||||
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
name: str = Query(...), value: str = Query(default="")):
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
@@ -1354,7 +1308,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
||||
async def get_ai_keywords(owner: str, repo: str):
|
||||
def get_ai_keywords(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||
@@ -1379,7 +1333,7 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
async def create_page(request: Request, title: str = Query(default=""),
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
@@ -1407,8 +1361,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -1417,7 +1371,7 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(request: Request, page_id: int):
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
@@ -1433,7 +1387,7 @@ async def get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
@@ -1459,24 +1413,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")})
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
@@ -1526,9 +1476,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid})
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1565,7 +1515,7 @@ def _block_texts(b: dict) -> list[str]:
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
async def page_backlinks(request: Request, page_id: int):
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
@@ -1613,7 +1563,7 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
async def page_versions(request: Request, page_id: int):
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1627,7 +1577,7 @@ async def page_versions(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
@@ -1641,8 +1591,8 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"})
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
@@ -1650,7 +1600,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
async def duplicate_page(request: Request, page_id: int):
|
||||
def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -1686,8 +1636,8 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
await fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")})
|
||||
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")}))
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
@@ -1695,8 +1645,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 +1681,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}"
|
||||
@@ -1773,7 +1722,7 @@ async def set_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
async def remove_page_cover(request: Request, page_id: int):
|
||||
def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -1781,9 +1730,8 @@ async def remove_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
async def set_page_icon(request: Request, page_id: int):
|
||||
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
body = await request.json()
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
@@ -1933,8 +1881,7 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
@@ -1958,16 +1905,12 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
def resolve_embed(request: Request, body: dict = Body(...)):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
@@ -1978,7 +1921,7 @@ async def resolve_embed(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
@@ -1986,10 +1929,6 @@ async def move_page(request: Request, page_id: int):
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
@@ -2020,13 +1959,13 @@ async def move_page(request: Request, page_id: int):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id})
|
||||
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -2040,14 +1979,14 @@ 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 ""})
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
|
||||
@@ -8,12 +8,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import notifications as notif
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
@@ -49,7 +50,7 @@ def _serialize(rows):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
def list_comments(request: Request, page_id: int):
|
||||
"""List page-level and inline comments for a FlowDeck page."""
|
||||
_current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -70,10 +71,9 @@ async def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Create a page or inline comment. Mentions (@login) notify users."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = (body.get("body") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -124,25 +124,24 @@ async def add_comment(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid}))
|
||||
mentioned_ids = notif.extract_mentions(text)
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_comment")
|
||||
|
||||
return {"id": comment_id, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def notify_page_mentions(request: Request, page_id: int):
|
||||
def notify_page_mentions(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Notify users @-mentioned in a page's content (called on save).
|
||||
|
||||
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
|
||||
against a per-page cache so repeated auto-saves don't spam notifications.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = body.get("text") or ""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
@@ -157,17 +156,16 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("notify_page_mentions")
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
"""Update a comment body or resolve/unresolve it."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM comments WHERE id=?", (comment_id,)
|
||||
@@ -188,14 +186,14 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if body.get("resolved") and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_comment")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: int):
|
||||
def delete_comment(request: Request, comment_id: int):
|
||||
"""Delete a comment and its replies."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
+84
-171
@@ -6,12 +6,12 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
@@ -27,6 +27,7 @@ from app.services.recurrence import (
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_collections(request: Request):
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def list_collections_api(request: Request):
|
||||
def list_collections_api(request: Request):
|
||||
"""API: list all collections."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -221,12 +222,8 @@ async def list_collections_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
async def create_collection_api(request: Request):
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -276,7 +273,7 @@ async def create_collection_api(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -284,12 +281,8 @@ async def create_collection_api(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
async def update_collection_api(request: Request, collection_id: int):
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -315,12 +308,12 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
@@ -339,13 +332,13 @@ async def delete_collection_api(request: Request, collection_id: int):
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""})
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""}))
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/duplicate")
|
||||
async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
|
||||
sources) into a new collection named '<original> (copy)'."""
|
||||
with get_conn() as conn:
|
||||
@@ -493,7 +486,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
@@ -501,7 +494,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
async def get_page_api(request: Request, page_id: int):
|
||||
def get_page_api(request: Request, page_id: int):
|
||||
"""API: get a single page."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
@@ -515,7 +508,7 @@ async def get_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
async def open_row_page_api(request: Request, page_id: int):
|
||||
def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
@@ -541,12 +534,8 @@ async def open_row_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
async def update_page_api(request: Request, page_id: int):
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -598,18 +587,18 @@ async def update_page_api(request: Request, page_id: int):
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
await fire_event("collection.page.updated", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
@@ -619,7 +608,7 @@ async def update_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
async def delete_page_api(request: Request, page_id: int):
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -633,15 +622,15 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.deleted", {
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
await fire_event("collection.page.deleted", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -649,7 +638,7 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
async def list_boards_as_collections(request: Request):
|
||||
def list_boards_as_collections(request: Request):
|
||||
"""API: list all Gitea boards as pseudo-collections."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||
@@ -689,14 +678,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
async def list_property_types_api(request: Request):
|
||||
def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
@@ -719,7 +708,7 @@ async def list_properties_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
async def list_collection_members_api(request: Request, collection_id: int):
|
||||
def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
@@ -752,7 +741,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
async def collection_calendar_api(request: Request, collection_id: int,
|
||||
def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
@@ -837,7 +826,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
async def timezones_api(request: Request):
|
||||
def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -850,16 +839,12 @@ async def timezones_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
async def set_property_groups_api(request: Request, collection_id: int):
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: (re)assign properties to collapsible groups in the table header.
|
||||
|
||||
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
|
||||
omitted from any group have their group cleared. Empty group names clear.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
groups = body.get("groups", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -886,12 +871,8 @@ async def set_property_groups_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
async def create_property_api(request: Request, collection_id: int):
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -933,12 +914,8 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
async def update_property_api(request: Request, prop_id: int):
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -972,7 +949,7 @@ async def update_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
async def delete_property_api(request: Request, prop_id: int):
|
||||
def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -990,12 +967,8 @@ async def delete_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
async def create_relation_property(request: Request, collection_id: int):
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
@@ -1042,12 +1015,8 @@ async def create_relation_property(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
async def link_pages(request: Request, collection_id: int):
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
@@ -1116,12 +1085,8 @@ async def link_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
async def compute_rollup(request: Request):
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
@@ -1142,12 +1107,8 @@ async def compute_rollup(request: Request):
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
async def evaluate_formula(request: Request):
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
@@ -1166,7 +1127,7 @@ async def evaluate_formula(request: Request):
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
async def get_view_api(request: Request, view_id: int):
|
||||
def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1176,12 +1137,8 @@ async def get_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
async def update_view_config(request: Request, view_id: int):
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1208,12 +1165,8 @@ async def update_view_config(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
async def save_view_as(request: Request, collection_id: int):
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
@@ -1240,18 +1193,18 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
})
|
||||
}))
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
async def list_views_api(request: Request, collection_id: int):
|
||||
def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list views for a collection visible to the current user.
|
||||
|
||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||
@@ -1276,7 +1229,7 @@ async def list_views_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}/api")
|
||||
async def delete_view_api(request: Request, view_id: int):
|
||||
def delete_view_api(request: Request, view_id: int):
|
||||
"""API: delete a saved view."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1288,12 +1241,8 @@ async def delete_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
async def duplicate_view_api(request: Request, view_id: int):
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1318,12 +1267,12 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
})
|
||||
}))
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
@@ -1332,7 +1281,7 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1343,12 +1292,8 @@ async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
@@ -1379,23 +1324,23 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
@@ -1419,12 +1364,8 @@ async def aggregate_child_status(request: Request, collection_id: int, page_id:
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
@@ -1446,12 +1387,8 @@ async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
@@ -1494,7 +1431,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
|
||||
|
||||
|
||||
@router.get("/{collection_id}/sources/api")
|
||||
async def list_data_sources(request: Request, collection_id: int):
|
||||
def list_data_sources(request: Request, collection_id: int):
|
||||
"""API: list all data sources for a collection."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -1509,12 +1446,8 @@ async def list_data_sources(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/sources/api")
|
||||
async def add_data_source(request: Request, collection_id: int):
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
source_collection_id = body.get("source_collection_id")
|
||||
if not source_collection_id:
|
||||
@@ -1554,7 +1487,7 @@ async def add_data_source(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/sources/{source_id}/api")
|
||||
async def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
"""API: remove a data source from a collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1571,15 +1504,11 @@ async def remove_data_source(request: Request, collection_id: int, source_id: in
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
async def create_linked_database(request: Request, collection_id: int):
|
||||
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a linked database view from a source collection.
|
||||
A linked database copies the structure (views, filters, sorts) of a source
|
||||
but shares the same pages — edits to pages propagate to the source.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
body.get("workspace_id")
|
||||
@@ -1667,7 +1596,7 @@ async def create_linked_database(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/toggle-inline/api")
|
||||
async def toggle_inline(request: Request, collection_id: int):
|
||||
def toggle_inline(request: Request, collection_id: int):
|
||||
"""API: toggle a collection between full-page and inline mode."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
@@ -1692,12 +1621,8 @@ async def toggle_inline(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/inline/api")
|
||||
async def create_inline_database(request: Request):
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -1758,7 +1683,7 @@ async def create_inline_database(request: Request):
|
||||
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
async def toggle_task(request: Request, collection_id: int):
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -1771,7 +1696,7 @@ async def toggle_task(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list dependencies for a page (blocks, blocked_by, related)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1791,12 +1716,8 @@ async def list_page_dependencies(request: Request, collection_id: int, page_id:
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
|
||||
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
dependency_id = body.get("dependency_id")
|
||||
if not dependency_id:
|
||||
raise HTTPException(status_code=400, detail="dependency_id is required")
|
||||
@@ -1819,7 +1740,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
|
||||
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
"""API: remove a dependency."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1833,15 +1754,11 @@ async def remove_page_dependency(request: Request, collection_id: int, page_id:
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -1914,7 +1831,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
@@ -1980,7 +1897,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
@@ -2375,8 +2292,8 @@ def _render_chart(view_type: str, collection: dict, pages: list[dict], config: d
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
data: {{
|
||||
@@ -2439,7 +2356,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
@@ -2487,10 +2404,10 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
||||
<script>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
@@ -2617,7 +2534,7 @@ tr:hover td{{background:#222}}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
async def get_collection_api(request: Request, collection_id: int):
|
||||
def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
@@ -2645,15 +2562,11 @@ async def get_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
async def create_page_api(request: Request, collection_id: int):
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
@@ -2694,16 +2607,16 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
+114
-107
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, 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
|
||||
|
||||
@@ -52,7 +54,7 @@ def _get_user_or_redirect(request: Request):
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
@@ -88,7 +90,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
for repo in repos[:10]:
|
||||
@@ -179,7 +181,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
@@ -193,7 +195,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
@@ -206,7 +208,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
@@ -250,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
@@ -277,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
@@ -306,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page_root(request: Request, page_id: int):
|
||||
def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
@@ -411,17 +413,36 @@ async def view_page_root(request: Request, page_id: int):
|
||||
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
||||
page_is_shared = (
|
||||
bool(page.get("is_shared", 0))
|
||||
or page.get("share_mode", "private") != "private"
|
||||
or bool(page.get("published", 0))
|
||||
)
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||
"page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_is_shared": page_is_shared,
|
||||
"page_data": page_data,
|
||||
"collection_data": collection_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
||||
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
||||
# ctx ci-dessus.
|
||||
from app.templating import ENV as _ENV27
|
||||
page_data.update(
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
user_id=_uid or 0,
|
||||
is_shared=page_is_shared,
|
||||
clip_icon=_ENV27.from_string(
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view
|
||||
if page.get("content_format") == "collection" and not embed:
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
@@ -432,21 +453,24 @@ async def view_page_root(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -651,7 +675,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -696,8 +720,7 @@ def _require_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
def update_profile(request: Request, body: dict = Body(default={})):
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -707,9 +730,8 @@ async def update_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
body = await request.json()
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
@@ -726,7 +748,7 @@ async def update_password(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/user/token")
|
||||
async def generate_token(request: Request):
|
||||
def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
@@ -740,7 +762,7 @@ async def generate_token(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
async def disconnect_forge(request: Request, provider: str):
|
||||
def disconnect_forge(request: Request, provider: str):
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -771,7 +793,7 @@ async def dashboard(
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
@@ -788,7 +810,7 @@ async def dashboard(
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
@@ -801,7 +823,7 @@ async def dashboard(
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
@@ -829,7 +851,7 @@ async def dashboard(
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -846,7 +868,7 @@ async def workspace_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
async def gitea_workspace_page(request: Request):
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
@@ -942,14 +964,13 @@ async def list_workspace_projects(request: Request):
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
async def create_workspace_project(request: Request):
|
||||
body = await request.json()
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -966,7 +987,7 @@ async def create_workspace_project(request: Request):
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
@router.get("/api/workspace/{ws_id:int}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
def list_members(request: Request, ws_id: int):
|
||||
"""List all members of a workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -978,9 +999,8 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
async def invite_member(request: Request, ws_id: int):
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
body = await request.json()
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
@@ -1001,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
body = await request.json()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
@@ -1017,7 +1036,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
"""Remove a member from a workspace."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1031,7 +1050,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
async def local_workspace_page(request: Request, folder: int = None):
|
||||
def local_workspace_page(request: Request, folder: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
@@ -1079,7 +1098,7 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
async def local_workspace_tree(request: Request, folder: int = None):
|
||||
def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
@@ -1104,7 +1123,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
||||
async def get_page_content(page_id: int):
|
||||
def get_page_content(page_id: int):
|
||||
"""Return the raw content of a page (for preview)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -1142,9 +1161,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)
|
||||
@@ -1158,9 +1176,21 @@ def _file_page_disk_path(page: dict):
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1181,13 +1211,15 @@ async def download_page_file(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1207,7 +1239,7 @@ async def page_file_content(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
@@ -1375,7 +1407,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
|
||||
|
||||
|
||||
@router.get("/api/nav/menu")
|
||||
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||
|
||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||
@@ -1431,9 +1463,8 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
async def create_local_workspace_item(request: Request):
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -1454,9 +1485,8 @@ async def create_local_workspace_item(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
@@ -1465,20 +1495,20 @@ async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
from datetime import datetime
|
||||
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"}
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1490,12 +1520,11 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
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:
|
||||
@@ -1511,9 +1540,8 @@ async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
async def move_local_workspace_item(request: Request, item_id: int):
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
body = await request.json()
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1553,11 +1581,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)
|
||||
|
||||
@@ -1641,11 +1668,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)
|
||||
|
||||
@@ -1759,7 +1785,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -1776,7 +1802,7 @@ async def workspaces_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/workspaces")
|
||||
async def list_workspaces(request: Request):
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -1795,9 +1821,8 @@ async def list_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
async def create_workspace(request: Request):
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1827,9 +1852,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
async def rename_workspace(request: Request, ws_id: int):
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1840,7 +1864,7 @@ async def rename_workspace(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
async def delete_workspace(request: Request, ws_id: int):
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace and all its pages."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
@@ -1851,7 +1875,7 @@ async def delete_workspace(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
async def select_workspace(request: Request, ws_id: int):
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
@@ -1862,7 +1886,7 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
# ═══════════ Settings Page ═══════════
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -1908,7 +1932,7 @@ async def upload_avatar(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/avatar/{filename:path}")
|
||||
async def serve_avatar_file(filename: str):
|
||||
def serve_avatar_file(filename: str):
|
||||
"""Serve an uploaded avatar image file."""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1927,7 +1951,7 @@ async def serve_avatar_file(filename: str):
|
||||
|
||||
|
||||
@router.get("/api/avatar/{user_id:int}")
|
||||
async def get_avatar(user_id: int):
|
||||
def get_avatar(user_id: int):
|
||||
"""Redirect to the user's avatar."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
@@ -1937,9 +1961,8 @@ async def get_avatar(user_id: int):
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
async def set_avatar_color(request: Request):
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
body = await request.json()
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1953,9 +1976,8 @@ async def set_avatar_color(request: Request):
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
async def create_tag_global(request: Request):
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -1973,9 +1995,8 @@ async def create_tag_global(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
async def update_tag_global(tag_id: int, request: Request):
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
body = await request.json()
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
@@ -1987,7 +2008,7 @@ async def update_tag_global(tag_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
async def delete_tag_global(tag_id: int, request: Request):
|
||||
def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -1998,7 +2019,7 @@ async def delete_tag_global(tag_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
async def list_all_tags_global(request: Request):
|
||||
def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -2017,7 +2038,7 @@ async def list_all_tags_global(request: Request):
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
async def list_tags(request: Request):
|
||||
def list_tags(request: Request):
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
@@ -2040,7 +2061,7 @@ async def list_tags(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def get_item_tags(item_id: int):
|
||||
def get_item_tags(item_id: int):
|
||||
"""Get tags for a specific item."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -2053,9 +2074,8 @@ async def get_item_tags(item_id: int):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def add_item_tag(request: Request, item_id: int):
|
||||
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -2083,13 +2103,13 @@ async def add_item_tag(request: Request, item_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
||||
async def remove_item_tag(item_id: int, tag_id: int):
|
||||
def remove_item_tag(item_id: int, tag_id: int):
|
||||
"""Remove a tag from an item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -2101,7 +2121,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tags/search")
|
||||
async def search_by_tags(request: Request, tags: str = ""):
|
||||
def search_by_tags(request: Request, tags: str = ""):
|
||||
"""Search items by tags (comma-separated)."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
@@ -2141,16 +2161,12 @@ async def search_by_tags(request: Request, tags: str = ""):
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
async def update_account(request: Request):
|
||||
def update_account(request: Request, body: dict = Body(default={})):
|
||||
"""Update current user's profile: full_name, login, email, password."""
|
||||
from app.password_utils import hash_password
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
@@ -2183,7 +2199,7 @@ async def update_account(request: Request):
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
async def sidebar_workspace_tree(request: Request):
|
||||
def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
@@ -2245,7 +2261,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from app.templating import ENV
|
||||
|
||||
@@ -2510,7 +2526,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
async def api_page_content(page_id: int):
|
||||
def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2529,9 +2545,8 @@ async def api_page_content(page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
async def api_rename_page(page_id: int, request: Request):
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
@@ -2548,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
async def api_trash_page(page_id: int):
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -2560,17 +2575,13 @@ async def api_trash_page(page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
async def api_convert_to_database(page_id: int, request: Request):
|
||||
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -2626,7 +2637,7 @@ async def api_convert_to_database(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
||||
async def api_collection_table_data(collection_id: int):
|
||||
def api_collection_table_data(collection_id: int):
|
||||
"""Get collection properties + pages for rendering the table view."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
@@ -2665,13 +2676,9 @@ async def api_collection_table_data(collection_id: int):
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
async def api_create_collection_page(collection_id: int, request: Request):
|
||||
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new page (row) in a collection."""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
|
||||
|
||||
|
||||
@router.get("/api/custom-emojis")
|
||||
async def list_custom_emojis(request: Request):
|
||||
def list_custom_emojis(request: Request):
|
||||
"""List the current workspace's custom emojis."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
@@ -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}"
|
||||
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
||||
async def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
"""Delete a custom emoji (and its stored file)."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
+19
-9
@@ -23,7 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["export"], prefix="/api/export")
|
||||
|
||||
|
||||
def _load_page_or_404(page_id: int) -> dict:
|
||||
def _load_page_or_404(request: Request, page_id: int) -> dict:
|
||||
"""A16 : session obligatoire + `PermissionManager.can_view_page` — l'export ne
|
||||
doit pas délivrer le contenu d'une page énumérable par id."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
@@ -31,6 +39,8 @@ def _load_page_or_404(page_id: int) -> dict:
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -51,8 +61,8 @@ def _safe_filename(page: dict, ext: str) -> str:
|
||||
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
md = page_to_markdown(page)
|
||||
filename = _safe_filename(page, "md")
|
||||
headers = _download_header(filename, "text/markdown")
|
||||
@@ -60,8 +70,8 @@ async def export_markdown(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
html = page_to_standalone_html(page)
|
||||
filename = _safe_filename(page, "html")
|
||||
headers = _download_header(filename, "text/html")
|
||||
@@ -69,8 +79,8 @@ async def export_html(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
except ImportError:
|
||||
@@ -84,8 +94,8 @@ async def export_pdf(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
site_bytes = build_static_site_bytes(page)
|
||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||
filename = f"{title}_site.zip"
|
||||
|
||||
+9
-25
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
@@ -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."""
|
||||
@@ -170,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
|
||||
# ── Account linking ──
|
||||
|
||||
@router.get("/status")
|
||||
async def gitea_status(request: Request):
|
||||
def gitea_status(request: Request):
|
||||
"""Check if the current user has Gitea linked."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
@@ -178,7 +170,7 @@ async def gitea_status(request: Request):
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_gitea(request: Request):
|
||||
def disconnect_gitea(request: Request):
|
||||
"""Remove all Gitea OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -194,7 +186,7 @@ async def disconnect_gitea(request: Request):
|
||||
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages")
|
||||
async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
def list_private_pages(owner: str, repo: str, request: Request):
|
||||
"""List private pages for this Gitea project."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -210,7 +202,7 @@ async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||
async def create_private_page(owner: str, repo: str, request: Request):
|
||||
def create_private_page(owner: str, repo: str, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -218,10 +210,6 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -233,7 +221,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Get a single private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -251,7 +239,7 @@ async def get_private_page(owner: str, repo: str, page_id: int, request: Request
|
||||
|
||||
|
||||
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def update_private_page(owner: str, repo: str, page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a private page."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -259,10 +247,6 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "").strip()
|
||||
content = body.get("content", "")
|
||||
with get_conn() as conn:
|
||||
@@ -280,7 +264,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
|
||||
|
||||
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Delete a private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -314,7 +298,7 @@ async def sync_labels(request: Request, owner: str, repo: str):
|
||||
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.db import get_conn
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
gitea = _require_gitea(request)
|
||||
|
||||
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def github_status(request: Request):
|
||||
def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -22,7 +22,7 @@ async def github_status(request: Request):
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_github(request: Request):
|
||||
def disconnect_github(request: Request):
|
||||
"""Remove all GitHub OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-policies")
|
||||
async def list_policies(request: Request):
|
||||
def list_policies(request: Request):
|
||||
_owner_or_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
||||
@@ -45,12 +45,8 @@ async def list_policies(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-policies")
|
||||
async def upsert_policy(request: Request):
|
||||
def upsert_policy(request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
wid = body.get("workspace_id")
|
||||
tools = body.get("allowed_tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
@@ -74,7 +70,7 @@ async def upsert_policy(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-approvals")
|
||||
async def list_approvals(request: Request):
|
||||
def list_approvals(request: Request):
|
||||
_owner_or_admin(request)
|
||||
status = request.query_params.get("status", "pending")
|
||||
with get_conn() as conn:
|
||||
@@ -84,12 +80,8 @@ async def list_approvals(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: int, request: Request):
|
||||
def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
|
||||
if out is None:
|
||||
raise HTTPException(404, "Pending approval not found")
|
||||
|
||||
@@ -39,7 +39,7 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async def import_page(request: Request):
|
||||
def import_page(request: Request):
|
||||
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def import_sources(request: Request):
|
||||
def import_sources(request: Request):
|
||||
"""List every available importer for the UI source picker."""
|
||||
return {"sources": list_sources()}
|
||||
|
||||
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
|
||||
|
||||
|
||||
@router.post("/relations/resolve")
|
||||
async def import_resolve_relations(request: Request):
|
||||
def import_resolve_relations(request: Request):
|
||||
"""Convert text columns referencing another collection into relation props."""
|
||||
ws_id, _ = _workspace(request)
|
||||
with get_conn() as conn:
|
||||
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def import_jobs(request: Request):
|
||||
def import_jobs(request: Request):
|
||||
return {"jobs": list_jobs()}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def import_job(job_id: str):
|
||||
def import_job(job_id: str):
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/report")
|
||||
async def import_job_report(job_id: str):
|
||||
def import_job_report(job_id: str):
|
||||
"""Download a job's import report as JSON."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
|
||||
+8
-161
@@ -157,7 +157,7 @@ BASE_SELECT = (
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
async def library_recents(
|
||||
def library_recents(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -191,7 +191,7 @@ async def library_recents(
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
async def library_favorites(
|
||||
def library_favorites(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -221,7 +221,7 @@ async def library_favorites(
|
||||
|
||||
|
||||
@router.get("/shared")
|
||||
async def library_shared(
|
||||
def library_shared(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -292,7 +292,7 @@ async def library_shared(
|
||||
|
||||
|
||||
@router.get("/published")
|
||||
async def library_published(
|
||||
def library_published(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -317,7 +317,7 @@ async def library_published(
|
||||
|
||||
|
||||
@router.get("/private")
|
||||
async def library_private(
|
||||
def library_private(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -341,76 +341,8 @@ async def library_private(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace-children/{item_id:int}")
|
||||
async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"""Return children of a local workspace item for tree expansion."""
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
|
||||
[item_id],
|
||||
).fetchone()
|
||||
if not item:
|
||||
return {"items": []}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE parent_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[item_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
with get_conn() as conn:
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": "",
|
||||
"workspace": "",
|
||||
"workspace_name": "",
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/children/{page_id:int}")
|
||||
async def library_children(page_id: int, request: Request):
|
||||
def library_children(page_id: int, request: Request):
|
||||
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -426,7 +358,7 @@ async def library_children(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/repository")
|
||||
async def library_repository(
|
||||
def library_repository(
|
||||
request: Request,
|
||||
gitea_owner: str = Query(default=""),
|
||||
gitea_repo: str = Query(default=""),
|
||||
@@ -449,93 +381,8 @@ async def library_repository(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace")
|
||||
async def library_local_workspace(
|
||||
request: Request,
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
"""Return local workspace items (files/folders) formatted for Library display."""
|
||||
from app.routers.dashboard import _get_active_workspace
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Get the active workspace
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
if not ws:
|
||||
return {"items": []}
|
||||
|
||||
ws_id = workspace_id or ws["id"]
|
||||
|
||||
# Query local workspace tree
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE workspace_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[ws_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
# Check for children
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": ws.get("name", "Workspace"),
|
||||
"workspace": ws.get("name", ""),
|
||||
"workspace_name": ws.get("name", ""),
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
def _format_size(size_bytes):
|
||||
if not size_bytes:
|
||||
return ""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1048576:
|
||||
return f"{size_bytes/1024:.1f} KB"
|
||||
if size_bytes < 1073741824:
|
||||
return f"{size_bytes/1048576:.1f} MB"
|
||||
return f"{size_bytes/1073741824:.1f} GB"
|
||||
|
||||
|
||||
@router.get("/workspace")
|
||||
async def library_workspace(
|
||||
def library_workspace(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
|
||||
+6
-14
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -46,12 +46,8 @@ def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
# ── calendar links ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/calendar-links")
|
||||
async def create_link(request: Request):
|
||||
def create_link(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
provider = (body.get("provider") or "").lower()
|
||||
if provider not in cal.PROVIDERS:
|
||||
raise HTTPException(400, "provider must be google|caldav")
|
||||
@@ -75,13 +71,13 @@ async def create_link(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/calendar-links")
|
||||
async def get_links(request: Request):
|
||||
def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
async def remove_link(link_id: int, request: Request):
|
||||
def remove_link(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
if not cal.delete_link(user["id"], link_id):
|
||||
raise HTTPException(404, "Link not found")
|
||||
@@ -108,7 +104,7 @@ async def sync_now(link_id: int, request: Request):
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/db/{collection_id}/calendar/freebusy")
|
||||
async def freebusy(collection_id: int, request: Request):
|
||||
def freebusy(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
qp = request.query_params
|
||||
try:
|
||||
@@ -171,13 +167,9 @@ async def upload_and_transcribe(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
|
||||
async def set_transcript_text(transcript_id: int, request: Request):
|
||||
def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "transcript required")
|
||||
|
||||
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
|
||||
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def get_notes(request: Request, owner: str, repo: str):
|
||||
def get_notes(request: Request, owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(request: Request, limit: int = 50):
|
||||
def list_notifications(request: Request, limit: int = 50):
|
||||
"""List the current user's notifications, newest first."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(request: Request):
|
||||
def unread_count(request: Request):
|
||||
"""Unread count for the topbar badge."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -56,10 +56,9 @@ async def unread_count(request: Request):
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
def mark_read(request: Request, body: dict = Body(default={})):
|
||||
"""Mark one notification as read (id) or all (id omitted)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
nid = body.get("id")
|
||||
with get_conn() as conn:
|
||||
if nid:
|
||||
@@ -83,7 +82,7 @@ async def mark_all_read(request: Request):
|
||||
|
||||
|
||||
@router.get("/prefs")
|
||||
async def get_prefs(request: Request):
|
||||
def get_prefs(request: Request):
|
||||
"""Return the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
@@ -91,11 +90,10 @@ async def get_prefs(request: Request):
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
def set_prefs(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prefs = notif.get_user_prefs(user["id"])
|
||||
for key in ("comments", "mentions", "reminders", "assignments"):
|
||||
if key in body:
|
||||
@@ -105,7 +103,7 @@ async def set_prefs(request: Request):
|
||||
|
||||
|
||||
@router.get("/timezone")
|
||||
async def get_timezone(request: Request):
|
||||
def get_timezone(request: Request):
|
||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -116,10 +114,9 @@ async def get_timezone(request: Request):
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
def set_timezone(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's IANA timezone (empty string = UTC)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
tz = (body.get("timezone") or "").strip()
|
||||
from app.services.recurrence import is_valid_timezone
|
||||
if tz and not is_valid_timezone(tz):
|
||||
@@ -131,7 +128,7 @@ async def set_timezone(request: Request):
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/welcome", response_class=HTMLResponse)
|
||||
async def onboarding_page(request: Request):
|
||||
def onboarding_page(request: Request):
|
||||
"""Onboarding wizard. Redirects logged-out users to login and users who
|
||||
already have a workspace straight to the app."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -63,13 +63,9 @@ def _forge_configured(provider: str) -> bool:
|
||||
|
||||
|
||||
@router.post("/api/onboarding/workspace")
|
||||
async def onboarding_create_workspace(request: Request):
|
||||
def onboarding_create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Step 1 — create the first local workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip() or "My Workspace"
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -90,13 +86,9 @@ async def onboarding_create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/onboarding/project")
|
||||
async def onboarding_create_project(request: Request):
|
||||
def onboarding_create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Step 3 — create the first project: a welcome page in the workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
|
||||
workspace_id = body.get("workspace_id")
|
||||
|
||||
|
||||
+22
-29
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async def list_page_permissions(page_id: int, request: Request):
|
||||
def list_page_permissions(page_id: int, request: Request):
|
||||
"""List explicit page grants + the caller's effective role."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async def my_page_permission(page_id: int, request: Request):
|
||||
def my_page_permission(page_id: int, request: Request):
|
||||
"""Effective role of the current user on a page (UI gating)."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
@@ -197,22 +197,20 @@ def _page_type(page_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "page", page_id, body,
|
||||
"page_permissions", "page_id", PAGE_ROLES)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions/batch")
|
||||
async def batch_page_permissions(page_id: int, request: Request):
|
||||
def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
grants = body.get("grants") or []
|
||||
if not isinstance(grants, list) or not grants:
|
||||
raise HTTPException(400, "grants must be a non-empty list")
|
||||
@@ -224,7 +222,7 @@ async def batch_page_permissions(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
@@ -243,7 +241,7 @@ async def set_page_permission_type(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async def list_collection_permissions(collection_id: int, request: Request):
|
||||
def list_collection_permissions(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
@@ -266,17 +264,16 @@ def _collection_type(collection_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
||||
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
@@ -294,7 +291,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async def visible_properties(collection_id: int, request: Request):
|
||||
def visible_properties(collection_id: int, request: Request):
|
||||
"""Split property ids into visible / hidden for the current user."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
@@ -316,7 +313,7 @@ async def visible_properties(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
@@ -331,11 +328,10 @@ async def list_property_permissions(collection_id: int, property_id: int, reques
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
@@ -349,7 +345,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
def revoke_property_permission(collection_id: int, property_id: int,
|
||||
perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
@@ -362,16 +358,15 @@ async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(request: Request, workspace_id: int | None = None):
|
||||
def list_groups(request: Request, workspace_id: int | None = None):
|
||||
user = _require_user(request)
|
||||
pm = PermissionManager(user["id"])
|
||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
def create_group(request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
ws_id = body.get("workspace_id")
|
||||
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
||||
created_by=pm.user_id)
|
||||
@@ -382,9 +377,8 @@ async def create_group(request: Request):
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
def update_group(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -405,7 +399,7 @@ async def update_group(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -422,15 +416,14 @@ async def delete_group(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async def list_group_members(group_id: int, request: Request):
|
||||
def list_group_members(group_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
def add_group_member(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
user_id = body.get("user_id")
|
||||
if not user_id or not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id is required")
|
||||
@@ -451,7 +444,7 @@ async def add_group_member(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -473,7 +466,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
"""Workspace members (+ admins) for the grant pickers."""
|
||||
_require_user(request)
|
||||
q = (q or "").strip().lower()
|
||||
@@ -503,7 +496,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import projects as projects_svc
|
||||
@@ -22,16 +22,15 @@ def _require_admin(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(request: Request):
|
||||
def list_projects(request: Request):
|
||||
"""List all synced projects (optionally filtered by type)."""
|
||||
proj_type = request.query_params.get("type") or None
|
||||
return {"projects": projects_svc.list_projects(proj_type)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(request: Request):
|
||||
def create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Register a standalone (builtin) project."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
@@ -51,7 +50,7 @@ async def sync_projects(request: Request):
|
||||
|
||||
|
||||
@backups_router.post("/api/settings/backups/run")
|
||||
async def run_backup_now(request: Request):
|
||||
def run_backup_now(request: Request):
|
||||
"""Admin: create a database backup immediately."""
|
||||
_require_admin(request)
|
||||
filename = backup_db()
|
||||
@@ -61,7 +60,7 @@ async def run_backup_now(request: Request):
|
||||
|
||||
|
||||
@backups_router.get("/api/settings/backups")
|
||||
async def admin_list_backups(request: Request):
|
||||
def admin_list_backups(request: Request):
|
||||
"""Admin: list stored backups."""
|
||||
_require_admin(request)
|
||||
return {"backups": list_backups()}
|
||||
|
||||
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
def generate_token(request: Request):
|
||||
"""Generate a public API access token (A4 : session obligatoire — plus de
|
||||
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -72,14 +72,14 @@ async def generate_token(request: Request):
|
||||
|
||||
|
||||
@router.get("/collections", dependencies=[Depends(verify_token)])
|
||||
async def public_list_collections(request: Request):
|
||||
def public_list_collections(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_collection(request: Request, collection_id: int):
|
||||
def public_get_collection(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
@@ -92,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
|
||||
async def public_list_pages(request: Request, collection_id: int):
|
||||
def public_list_pages(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_page(request: Request, page_id: int):
|
||||
def public_get_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not p:
|
||||
@@ -111,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
|
||||
async def public_my_tasks(request: Request):
|
||||
def public_my_tasks(request: Request):
|
||||
"""Public API: list tasks (requires valid token)."""
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
|
||||
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.get("/api/realtime/stats")
|
||||
async def realtime_stats(request: Request):
|
||||
def realtime_stats(request: Request):
|
||||
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||
|
||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
|
||||
try:
|
||||
await websocket.close(code=4401)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("ws_page")
|
||||
return
|
||||
|
||||
conn = await manager.connect(websocket, page_id, user)
|
||||
|
||||
+15
-35
@@ -12,12 +12,13 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
router = APIRouter(tags=["scim"])
|
||||
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
@@ -60,7 +61,7 @@ def _scim_user(row) -> dict:
|
||||
# ── SCIM resources ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/scim/v2/Users")
|
||||
async def scim_list(request: Request):
|
||||
def scim_list(request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
||||
@@ -70,12 +71,8 @@ async def scim_list(request: Request):
|
||||
|
||||
|
||||
@router.post("/scim/v2/Users")
|
||||
async def scim_create(request: Request):
|
||||
def scim_create(request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = (body.get("userName") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(400, "userName required")
|
||||
@@ -99,7 +96,7 @@ async def scim_create(request: Request):
|
||||
|
||||
|
||||
@router.get("/scim/v2/Users/{user_id}")
|
||||
async def scim_get(user_id: str, request: Request):
|
||||
def scim_get(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -133,12 +130,8 @@ def _apply_scim_update(conn, user_id: str, body: dict) -> None:
|
||||
|
||||
|
||||
@router.put("/scim/v2/Users/{user_id}")
|
||||
async def scim_replace(user_id: str, request: Request):
|
||||
def scim_replace(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, body)
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -146,12 +139,8 @@ async def scim_replace(user_id: str, request: Request):
|
||||
|
||||
|
||||
@router.patch("/scim/v2/Users/{user_id}")
|
||||
async def scim_patch(user_id: str, request: Request):
|
||||
def scim_patch(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
flat: dict = {}
|
||||
for op in body.get("Operations") or []:
|
||||
path = (op.get("path") or "").lower()
|
||||
@@ -164,7 +153,7 @@ async def scim_patch(user_id: str, request: Request):
|
||||
|
||||
|
||||
@router.delete("/scim/v2/Users/{user_id}")
|
||||
async def scim_delete(user_id: str, request: Request):
|
||||
def scim_delete(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -180,12 +169,8 @@ async def scim_delete(user_id: str, request: Request):
|
||||
# ── SCIM token management (admin, session) ─────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/scim/tokens")
|
||||
async def create_scim_token(request: Request):
|
||||
def create_scim_token(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
raw = f"scim_{secrets.token_urlsafe(32)}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
@@ -200,7 +185,7 @@ async def create_scim_token(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/scim/tokens")
|
||||
async def list_scim_tokens(request: Request):
|
||||
def list_scim_tokens(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
||||
@@ -209,7 +194,7 @@ async def list_scim_tokens(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/scim/tokens/{token_id}")
|
||||
async def revoke_scim_token(token_id: int, request: Request):
|
||||
def revoke_scim_token(token_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
@@ -221,7 +206,7 @@ async def revoke_scim_token(token_id: int, request: Request):
|
||||
# ── domain claims ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/domain-claims")
|
||||
async def list_domains(request: Request):
|
||||
def list_domains(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
||||
@@ -234,12 +219,8 @@ async def list_domains(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims")
|
||||
async def create_domain(request: Request):
|
||||
def create_domain(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
domain = (body.get("domain") or "").strip().lower()
|
||||
if not domain or "." not in domain or "/" in domain:
|
||||
raise HTTPException(400, "valid domain required")
|
||||
@@ -266,7 +247,6 @@ async def create_domain(request: Request):
|
||||
@router.post("/api/v2/domain-claims/{domain_id}/verify")
|
||||
async def verify_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
import httpx
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -274,7 +254,7 @@ async def verify_domain(domain_id: int, request: Request):
|
||||
claim = dict(row)
|
||||
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
async with shared_client(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
|
||||
except Exception: # noqa: BLE001 — unreachable domain = not verified
|
||||
@@ -288,7 +268,7 @@ async def verify_domain(domain_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/domain-claims/{domain_id}")
|
||||
async def delete_domain(domain_id: int, request: Request):
|
||||
def delete_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
||||
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async def search(request: Request, q: str = Query(default="")):
|
||||
def search(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user_id = user.get("id") if user and user.get("id") else None
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def _auth_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/api/v2/search/hybrid")
|
||||
async def hybrid(request: Request):
|
||||
def hybrid(request: Request):
|
||||
user = _auth_user(request)
|
||||
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/search/index-status")
|
||||
async def index_status(request: Request):
|
||||
def index_status(request: Request):
|
||||
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -8,7 +8,7 @@ import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async def list_tokens(request: Request):
|
||||
def list_tokens(request: Request):
|
||||
"""List the current user's API tokens (prefix only, no secrets)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -45,10 +45,9 @@ async def list_tokens(request: Request):
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request):
|
||||
def create_token(request: Request, body: dict = Body(default={})):
|
||||
"""Create an API token for the current user. The secret is returned once."""
|
||||
uid = _current_user_id(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "API token"
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
@@ -63,7 +62,7 @@ async def create_token(request: Request):
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id:int}")
|
||||
async def revoke_token(token_id: int, request: Request):
|
||||
def revoke_token(token_id: int, request: Request):
|
||||
"""Revoke an API token (soft delete)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -81,7 +80,7 @@ async def revoke_token(token_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(request: Request):
|
||||
def list_sessions(request: Request):
|
||||
"""List the current user's active sessions with their devices."""
|
||||
uid = _current_user_id(request)
|
||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -101,7 +100,7 @@ async def list_sessions(request: Request):
|
||||
|
||||
|
||||
@router.post("/sessions/{sid}/revoke")
|
||||
async def revoke_session(sid: str, request: Request):
|
||||
def revoke_session(sid: str, request: Request):
|
||||
"""Revoke an active session. If it's the current one, the user is logged out."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -117,5 +116,5 @@ async def revoke_session(sid: str, request: Request):
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("revoke_session")
|
||||
return {"status": "revoked"}
|
||||
|
||||
+19
-74
@@ -2,15 +2,15 @@
|
||||
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 fastapi import APIRouter, Body, 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.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -24,22 +24,13 @@ 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 ──
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
def share_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
@@ -128,9 +119,9 @@ async def share_page(page_id: int, request: Request):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("share_page")
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
@@ -173,11 +164,10 @@ def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
@@ -213,7 +203,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
def remove_share(page_id: int, share_id: int, request: Request):
|
||||
"""Remove a share invitation."""
|
||||
_require_auth(request)
|
||||
|
||||
@@ -247,7 +237,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
async def list_shares(page_id: int, request: Request):
|
||||
def list_shares(page_id: int, request: Request):
|
||||
"""Get all shares for a page."""
|
||||
_require_auth(request)
|
||||
|
||||
@@ -302,38 +292,11 @@ async def list_shares(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
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:
|
||||
pass
|
||||
|
||||
slug, _title = publish(page_id)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -343,28 +306,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
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:
|
||||
pass
|
||||
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
@@ -376,10 +322,9 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/recents/track")
|
||||
async def track_recent(request: Request):
|
||||
def track_recent(request: Request, body: dict = Body(default={})):
|
||||
"""Record a page access in recents."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
workspace = body.get("workspace", "")
|
||||
source_type = body.get("source_type", "local")
|
||||
@@ -399,7 +344,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 +352,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(),
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_sidebar_config(request: Request):
|
||||
def get_sidebar_config(request: Request):
|
||||
"""Get the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -81,13 +81,9 @@ def get_sidebar_config_sync(user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def save_sidebar_config(request: Request):
|
||||
def save_sidebar_config(request: Request, body: dict = Body(...)):
|
||||
"""Save the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
config = body.get("config")
|
||||
if not config or not isinstance(config, dict):
|
||||
|
||||
+26
-37
@@ -12,13 +12,14 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -35,6 +36,8 @@ from app.services.api_v2_helpers import (
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["sites"])
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
|
||||
@@ -160,7 +163,7 @@ def _render_page_html(page: dict) -> str:
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_render_page_html")
|
||||
titles: dict = {}
|
||||
try:
|
||||
from app.db import get_conn as _gc
|
||||
@@ -223,7 +226,7 @@ def _track_view(site_id: int) -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_track_view")
|
||||
|
||||
|
||||
def _form_config(conn, collection_id: int) -> dict:
|
||||
@@ -253,12 +256,8 @@ def _check_form_rate(ip: str) -> None:
|
||||
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/sites")
|
||||
async def create_site(request: Request):
|
||||
def create_site(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
@@ -312,7 +311,7 @@ async def create_site(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites")
|
||||
async def list_sites(request: Request):
|
||||
def list_sites(request: Request):
|
||||
user = _auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
@@ -330,7 +329,7 @@ async def list_sites(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}")
|
||||
async def get_site(site_id: int, request: Request):
|
||||
def get_site(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -346,12 +345,8 @@ async def get_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
def update_site(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -404,7 +399,7 @@ async def update_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}")
|
||||
async def delete_site(site_id: int, request: Request):
|
||||
def delete_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -419,7 +414,7 @@ async def delete_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/pages")
|
||||
async def list_site_pages(site_id: int, request: Request):
|
||||
def list_site_pages(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -429,12 +424,8 @@ async def list_site_pages(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
def add_site_page(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -458,7 +449,7 @@ async def add_site_page(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
||||
async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -475,7 +466,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/stats")
|
||||
async def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
user = _auth_user(request)
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
with get_conn() as conn:
|
||||
@@ -513,7 +504,7 @@ def _public_guard(site: dict, request: Request):
|
||||
|
||||
|
||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
||||
async def public_site_home(request: Request, slug: str):
|
||||
def public_site_home(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug,
|
||||
host=request.headers.get("host", ""))
|
||||
@@ -534,7 +525,7 @@ async def public_site_home(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
||||
async def site_sitemap(request: Request, slug: str):
|
||||
def site_sitemap(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site or _site_expired(site) or site.get("password_hash"):
|
||||
@@ -553,7 +544,7 @@ async def site_sitemap(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
||||
async def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
@@ -589,12 +580,14 @@ async def public_site_auth(request: Request, slug: str):
|
||||
try:
|
||||
password = (await request.json()).get("password", "")
|
||||
except Exception:
|
||||
logger.exception("public_site_auth")
|
||||
password = ""
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
password = form.get("password", "")
|
||||
except Exception:
|
||||
logger.exception("public_site_auth")
|
||||
password = ""
|
||||
if not verify_password(password or "", site["password_hash"] or ""):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
@@ -609,7 +602,7 @@ async def public_site_auth(request: Request, slug: str):
|
||||
# ── Public Forms ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/collections/{collection_id}/form")
|
||||
async def get_form_config(collection_id: int, request: Request):
|
||||
def get_form_config(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
@@ -617,12 +610,8 @@ async def get_form_config(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
@@ -657,7 +646,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
|
||||
|
||||
|
||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
||||
async def public_form(request: Request, token: str):
|
||||
def public_form(request: Request, token: str):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||
@@ -781,7 +770,7 @@ async def submit_form(request: Request, token: str):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("submit_form")
|
||||
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
|
||||
@@ -808,12 +797,12 @@ async def submit_form(request: Request, token: str):
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("submit_form")
|
||||
try:
|
||||
from app.services.automations import fire_event as _fire
|
||||
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("submit_form")
|
||||
if "application/json" in ctype:
|
||||
return {"status": "ok", "row_id": row_id,
|
||||
"message": cfg.get("success_message") or "Merci !"}
|
||||
|
||||
+19
-23
@@ -17,13 +17,14 @@ import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
@@ -106,7 +107,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
|
||||
|
||||
|
||||
@router.get("/auth/saml/login")
|
||||
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||
if not _rate_ok(request, "saml"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
@@ -218,7 +219,7 @@ async def saml_callback(request: Request):
|
||||
|
||||
|
||||
@router.get("/auth/saml/metadata")
|
||||
async def saml_metadata(request: Request):
|
||||
def saml_metadata(request: Request):
|
||||
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
@@ -436,10 +437,9 @@ async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
@@ -496,7 +496,7 @@ async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=l
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/providers")
|
||||
async def sso_providers(request: Request):
|
||||
def sso_providers(request: Request):
|
||||
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg:
|
||||
@@ -558,22 +558,18 @@ async def sso_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
async def get_sso_config_api(request: Request):
|
||||
def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
async def save_sso_config_api(request: Request):
|
||||
def save_sso_config_api(request: Request, payload: dict = Body(...)):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
@@ -586,9 +582,9 @@ async def save_sso_config_api(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
admin = _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
@@ -597,9 +593,9 @@ async def delete_sso_config_api(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
async def sso_workspaces(request: Request):
|
||||
def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -616,9 +612,9 @@ async def sso_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
async def sso_sync(request: Request):
|
||||
def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
@@ -630,9 +626,9 @@ async def sso_sync(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
|
||||
+10
-18
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -88,7 +88,7 @@ def _require_user(request: Request) -> dict:
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
@@ -101,12 +101,8 @@ async def clipper_status(request: Request):
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
def auth_verify(request: Request, body: dict = Body(default={})):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
@@ -124,7 +120,7 @@ async def auth_verify(request: Request):
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
def clip_page(request: Request, body: dict = Body(...)):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
@@ -134,10 +130,6 @@ async def clip_page(request: Request):
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
@@ -179,7 +171,7 @@ async def clip_page(request: Request):
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("clip_page")
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
@@ -203,20 +195,20 @@ async def clip_page(request: Request):
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("clip_page")
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
@@ -227,7 +219,7 @@ async def revoke_extension_device(device_id: int, request: Request):
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
def extensions_page(request: Request):
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
@@ -249,7 +241,7 @@ async def extensions_page(request: Request):
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("extensions_page")
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
|
||||
+7
-19
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def register_begin(request: Request):
|
||||
def register_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_registration_options, options_to_json
|
||||
@@ -76,15 +76,11 @@ async def register_begin(request: Request):
|
||||
|
||||
|
||||
@router.post("/register/finish")
|
||||
async def register_finish(request: Request):
|
||||
def register_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
challenge = _take_challenge(f"reg:{user['id']}")
|
||||
if not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
@@ -115,14 +111,10 @@ async def register_finish(request: Request):
|
||||
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def login_begin(request: Request):
|
||||
def login_begin(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
if not login:
|
||||
raise HTTPException(400, "login required")
|
||||
@@ -144,14 +136,10 @@ async def login_begin(request: Request):
|
||||
|
||||
|
||||
@router.post("/login/finish")
|
||||
async def login_finish(request: Request):
|
||||
def login_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
challenge = _take_challenge(f"login:{login}")
|
||||
if not login or not challenge:
|
||||
@@ -193,7 +181,7 @@ async def login_finish(request: Request):
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_keys(request: Request):
|
||||
def list_keys(request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
||||
@@ -202,7 +190,7 @@ async def list_keys(request: Request):
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_key(key_id: int, request: Request):
|
||||
def delete_key(key_id: int, request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||
|
||||
+24
-48
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
|
||||
# ── teamspaces ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces")
|
||||
async def list_teamspaces(request: Request):
|
||||
def list_teamspaces(request: Request):
|
||||
user = _user(request)
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if wid:
|
||||
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
|
||||
|
||||
|
||||
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
|
||||
async def teamspace_page(teamspace_id: int, request: Request):
|
||||
def teamspace_page(teamspace_id: int, request: Request):
|
||||
"""Teamspace detail HTML page — sidebar entry point."""
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
@@ -172,12 +172,8 @@ async def teamspace_page(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/teamspaces")
|
||||
async def create_teamspace(request: Request):
|
||||
def create_teamspace(request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
raise HTTPException(400, "name required (max 120 chars)")
|
||||
@@ -204,7 +200,7 @@ async def create_teamspace(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
|
||||
async def get_teamspace(teamspace_id: int, request: Request):
|
||||
def get_teamspace(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
@@ -213,7 +209,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
|
||||
async def list_members(teamspace_id: int, request: Request):
|
||||
def list_members(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
with get_conn() as conn:
|
||||
@@ -225,15 +221,11 @@ async def list_members(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
def set_member(teamspace_id: int, member_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
role = body.get("role")
|
||||
if role not in wiki.TEAMSPACE_ROLES:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
|
||||
@@ -251,7 +243,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
@@ -268,22 +260,18 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
# ── verified pages ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/verification")
|
||||
async def get_verification(page_id: int, request: Request):
|
||||
def get_verification(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
return {"verification": wiki.verification(page_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def verify_page(page_id: int, request: Request):
|
||||
def verify_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if not _can_verify(user, page):
|
||||
raise HTTPException(403, "Editor role required to verify a page")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = wiki.verify_page(page_id, user["id"],
|
||||
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
|
||||
note=body.get("note") or "")
|
||||
@@ -292,7 +280,7 @@ async def verify_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def unverify_page(page_id: int, request: Request):
|
||||
def unverify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
if not wiki.unverify_page(page_id):
|
||||
@@ -302,7 +290,7 @@ async def unverify_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/verified")
|
||||
async def list_verified(request: Request):
|
||||
def list_verified(request: Request):
|
||||
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
@@ -331,7 +319,7 @@ async def list_verified(request: Request):
|
||||
# ── follows ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/follow")
|
||||
async def follow_page(page_id: int, request: Request):
|
||||
def follow_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
now = wiki.toggle_follow(page_id, user["id"])
|
||||
@@ -339,7 +327,7 @@ async def follow_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/followers")
|
||||
async def list_followers(page_id: int, request: Request):
|
||||
def list_followers(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
ids = wiki.followers(page_id)
|
||||
@@ -355,12 +343,8 @@ async def list_followers(page_id: int, request: Request):
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def react(comment_id: int, request: Request):
|
||||
def react(comment_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
raise HTTPException(400, "emoji required")
|
||||
@@ -372,7 +356,7 @@ async def react(comment_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def list_reactions(comment_id: int, request: Request):
|
||||
def list_reactions(comment_id: int, request: Request):
|
||||
_user(request)
|
||||
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
||||
|
||||
@@ -380,15 +364,11 @@ async def list_reactions(comment_id: int, request: Request):
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def create_guest(page_id: int, request: Request):
|
||||
def create_guest(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to share")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
share = wiki.create_guest_share(page_id, body.get("email") or "",
|
||||
body.get("role") or "viewer",
|
||||
@@ -402,7 +382,7 @@ async def create_guest(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def list_guests(page_id: int, request: Request):
|
||||
def list_guests(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
with get_conn() as conn:
|
||||
@@ -413,7 +393,7 @@ async def list_guests(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/guests/{share_id}")
|
||||
async def revoke_guest(share_id: int, request: Request):
|
||||
def revoke_guest(share_id: int, request: Request):
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
||||
@@ -436,7 +416,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
|
||||
|
||||
|
||||
@router.get("/g/{token}", response_class=HTMLResponse)
|
||||
async def guest_page(token: str, request: Request):
|
||||
def guest_page(token: str, request: Request):
|
||||
"""Account-less page access (read-only or commenter). 404 if inactive."""
|
||||
share = wiki.resolve_guest_share(token)
|
||||
if not share:
|
||||
@@ -465,7 +445,7 @@ Editing is disabled.</div>
|
||||
# ── page views ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/views")
|
||||
async def page_views(page_id: int, request: Request):
|
||||
def page_views(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
@@ -476,7 +456,7 @@ async def page_views(page_id: int, request: Request):
|
||||
# ── wiki home ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/home")
|
||||
async def wiki_home(request: Request):
|
||||
def wiki_home(request: Request):
|
||||
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
@@ -500,7 +480,7 @@ async def wiki_home(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/verify-expiry-sweep")
|
||||
async def sweep_expiry(request: Request):
|
||||
def sweep_expiry(request: Request):
|
||||
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -519,13 +499,9 @@ async def sweep_expiry(request: Request):
|
||||
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/blocks/preview")
|
||||
async def preview_blocks(request: Request):
|
||||
def preview_blocks(request: Request, body: dict = Body(default={})):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
|
||||
+9
-17
@@ -1,7 +1,7 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -41,12 +41,8 @@ def _row_to_api(row) -> dict:
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
def create_worker(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
@@ -72,7 +68,7 @@ async def create_worker(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers")
|
||||
async def list_workers(request: Request):
|
||||
def list_workers(request: Request):
|
||||
_auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
@@ -87,7 +83,7 @@ async def list_workers(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}")
|
||||
async def get_worker(worker_id: int, request: Request):
|
||||
def get_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
@@ -101,12 +97,8 @@ async def get_worker(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
def update_worker(worker_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -146,7 +138,7 @@ async def update_worker(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/workers/{worker_id}")
|
||||
async def delete_worker(worker_id: int, request: Request):
|
||||
def delete_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
@@ -186,7 +178,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
||||
async def worker_runs(worker_id: int, request: Request):
|
||||
def worker_runs(worker_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
limit, _offset = parse_pagination(request, default_limit=20)
|
||||
with get_conn() as conn:
|
||||
@@ -199,7 +191,7 @@ async def worker_runs(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
||||
async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
out = worker_service.fork_worker(worker_id, user["id"])
|
||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||
@@ -207,7 +199,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers-usage")
|
||||
async def workers_usage(request: Request):
|
||||
def workers_usage(request: Request):
|
||||
user = _auth_user(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
|
||||
+52
-71
@@ -8,12 +8,12 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -57,8 +57,7 @@ def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "Default Workspace")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
@@ -79,7 +78,7 @@ async def create_workspace(request: Request):
|
||||
# ── Members ──
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
def list_members(request: Request, ws_id: int):
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
@@ -91,9 +90,8 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
def add_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -108,9 +106,8 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
@@ -122,7 +119,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
@@ -133,7 +130,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ── Comments ──
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
def list_comments(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
|
||||
@@ -143,8 +140,7 @@ async def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body", "").strip()
|
||||
if not b:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -158,15 +154,14 @@ async def add_comment(request: Request, page_id: int):
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_comment")
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
@@ -178,16 +173,16 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if resolved and row and not int(row["resolved"] or 0):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_comment")
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
# ── Page History ──
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
async def page_history(request: Request, page_id: int):
|
||||
def page_history(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||
@@ -197,8 +192,7 @@ async def page_history(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history")
|
||||
async def record_history(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def record_history(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -214,7 +208,7 @@ async def record_history(request: Request, page_id: int):
|
||||
# ── Favorites ──
|
||||
|
||||
@router.get("/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -230,8 +224,7 @@ async def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
page_id = body.get("page_id")
|
||||
@@ -244,14 +237,14 @@ async def add_favorite(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
async def remove_favorite(request: Request, fav_id: int):
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
conn.commit()
|
||||
@@ -261,15 +254,14 @@ async def remove_favorite(request: Request, fav_id: int):
|
||||
# ── Templates ──
|
||||
|
||||
@router.get("/templates/database")
|
||||
async def list_db_templates(request: Request):
|
||||
def list_db_templates(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database")
|
||||
async def create_db_template(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_db_template(request: Request, body: dict = Body(default={})):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
@@ -282,9 +274,8 @@ async def create_db_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
def apply_db_template(request: Request, tid: int, body: dict = Body(default={})):
|
||||
from app.services.db_templates import create_from_template
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
@@ -296,7 +287,7 @@ async def apply_db_template(request: Request, tid: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
async def list_page_templates(request: Request, collection_id: int):
|
||||
def list_page_templates(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||
@@ -305,8 +296,7 @@ async def list_page_templates(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page")
|
||||
async def create_page_template(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||
@@ -317,8 +307,7 @@ async def create_page_template(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
|
||||
if not tmpl:
|
||||
@@ -331,19 +320,18 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"title": body.get("title", "New Page"),
|
||||
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||
})
|
||||
}))
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
"""Update a page template — name, properties, content, recurrence."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||
@@ -369,7 +357,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
"""Delete a page template."""
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
@@ -385,7 +373,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
# ── v4.2.0: Dashboards ──
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
async def list_dashboards(request: Request, collection_id: int):
|
||||
def list_dashboards(request: Request, collection_id: int):
|
||||
"""List all dashboards for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -395,9 +383,8 @@ async def list_dashboards(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
async def create_dashboard(request: Request, collection_id: int):
|
||||
def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new dashboard for a collection."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Dashboard").strip()
|
||||
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
|
||||
|
||||
@@ -414,9 +401,8 @@ async def create_dashboard(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/dashboards/{did}")
|
||||
async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})):
|
||||
"""Update a dashboard — name or layout (widgets grid)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||
@@ -436,7 +422,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/dashboards/{did}")
|
||||
async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
"""Delete a dashboard."""
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
@@ -452,7 +438,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
# ── v4.5.0: Sprints ──
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
async def list_sprints(request: Request, collection_id: int):
|
||||
def list_sprints(request: Request, collection_id: int):
|
||||
"""List all sprints for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -471,9 +457,8 @@ async def list_sprints(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "").strip()
|
||||
start_date = body.get("start_date", "")
|
||||
end_date = body.get("end_date", "")
|
||||
@@ -491,16 +476,15 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_sprint")
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Update a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
@@ -521,14 +505,14 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_sprint")
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
||||
async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Delete a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
@@ -542,9 +526,8 @@ async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Assign a page to a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -572,7 +555,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
||||
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
"""Remove a page from a sprint."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -586,7 +569,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
"""Calculate burndown data for a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
@@ -634,8 +617,7 @@ async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def import_csv(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
@@ -660,7 +642,7 @@ async def import_csv(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
async def export_csv(request: Request, collection_id: int):
|
||||
def export_csv(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||
@@ -690,7 +672,7 @@ async def export_csv(request: Request, collection_id: int):
|
||||
# ── Webhooks Outbound Management ──
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
@@ -698,9 +680,8 @@ async def list_webhooks(request: Request):
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
def create_webhook(request: Request, body: dict = Body(default={})):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
@@ -723,7 +704,7 @@ async def create_webhook(request: Request):
|
||||
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
def delete_webhook(request: Request, wh_id: int):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
@@ -734,7 +715,7 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def to_iso8601(value: str | None) -> str | None:
|
||||
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("row_to_dict")
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("resolve_bearer_token")
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("resolve_bearer_token")
|
||||
return d
|
||||
# 2) extension_devices
|
||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||
@@ -211,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
|
||||
@@ -257,7 +264,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("audit_log")
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
@@ -307,4 +314,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("store_idempotency")
|
||||
|
||||
@@ -24,12 +24,11 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -179,7 +178,7 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(url, json=context, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||
@@ -355,6 +354,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
def run_event_sync(coro, timeout: float = 60.0):
|
||||
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
|
||||
|
||||
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
|
||||
déterministe, exactement ce que faisait l'await avant la conversion des
|
||||
routes en `def`.
|
||||
ponytail: les clients httpx des services sont créés à chaque appel (aucun
|
||||
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
|
||||
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
|
||||
"""
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout))
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
||||
@@ -420,7 +432,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 +500,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)
|
||||
@@ -706,7 +718,7 @@ async def fire_stepped_event(event: str, payload: dict) -> None:
|
||||
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
|
||||
|
||||
async def _post_slack(webhook_url: str, text: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"text": text})
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
|
||||
@@ -752,7 +764,7 @@ async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
|
||||
if provider == "github":
|
||||
if not token:
|
||||
raise ValueError("github action needs a linked GitHub account (token)")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
|
||||
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("backup_db")
|
||||
|
||||
dest_dir = _backup_dir()
|
||||
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
||||
|
||||
@@ -19,9 +19,8 @@ import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,7 +124,7 @@ async def google_list_events(tokens: dict, calendar_id: str,
|
||||
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
|
||||
f"/events?singleEvents=true&orderBy=startTime"
|
||||
f"&timeMin={time_min}&timeMax={time_max}")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
@@ -150,7 +149,7 @@ async def google_push_event(tokens: dict, calendar_id: str, event: dict,
|
||||
"start": {"date": event.get("start", "")[:10]},
|
||||
"end": {"date": event.get("start", "")[:10]}}
|
||||
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
if remote_id:
|
||||
resp = await client.patch(f"{base}/{remote_id}",
|
||||
headers={"Authorization": f"Bearer {access}"}, json=body)
|
||||
@@ -224,7 +223,7 @@ async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[
|
||||
body = _CALDAV_REPORT.format(
|
||||
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
|
||||
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.request("REPORT", url, content=body,
|
||||
headers={"Depth": "1",
|
||||
"Content-Type": "application/xml"})
|
||||
@@ -245,7 +244,7 @@ async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> st
|
||||
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
|
||||
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
|
||||
event.get("start", ""), event.get("description", ""))
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
@@ -466,7 +465,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:
|
||||
|
||||
@@ -5,9 +5,8 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,7 +34,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 ──
|
||||
|
||||
@@ -45,7 +47,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/repos",
|
||||
headers=self._headers,
|
||||
@@ -62,7 +64,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/orgs/{org}/repos",
|
||||
headers=self._headers,
|
||||
@@ -79,7 +81,7 @@ class GiteaClient:
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
@@ -106,7 +108,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/orgs", headers=self._headers
|
||||
)
|
||||
@@ -125,7 +127,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
@@ -137,7 +139,7 @@ class GiteaClient:
|
||||
return data
|
||||
|
||||
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
@@ -160,7 +162,7 @@ class GiteaClient:
|
||||
payload["assignees"] = [assignee]
|
||||
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
@@ -174,7 +176,7 @@ class GiteaClient:
|
||||
) -> dict:
|
||||
"""Update an issue (title, body, state, labels, milestone, assignees)."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.patch(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
@@ -188,7 +190,7 @@ class GiteaClient:
|
||||
) -> list[dict]:
|
||||
"""Replace all labels on an issue (PUT endpoint)."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.put(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels",
|
||||
headers=self._headers,
|
||||
@@ -207,7 +209,7 @@ class GiteaClient:
|
||||
self, owner: str, repo: str, issue_id: int
|
||||
) -> list[dict]:
|
||||
"""Get comments for an issue."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
|
||||
headers=self._headers,
|
||||
@@ -229,7 +231,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/labels",
|
||||
headers=self._headers,
|
||||
@@ -249,7 +251,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/milestones",
|
||||
headers=self._headers,
|
||||
@@ -264,7 +266,7 @@ class GiteaClient:
|
||||
|
||||
async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List webhooks for a repo."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||
headers=self._headers,
|
||||
@@ -289,7 +291,7 @@ class GiteaClient:
|
||||
"events": events,
|
||||
"active": True,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||
headers=self._headers,
|
||||
@@ -300,7 +302,7 @@ class GiteaClient:
|
||||
|
||||
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool:
|
||||
"""Delete a webhook."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.delete(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}",
|
||||
headers=self._headers,
|
||||
@@ -316,7 +318,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/collaborators",
|
||||
headers=self._headers,
|
||||
@@ -338,7 +340,7 @@ class GiteaClient:
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(url, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -354,7 +356,7 @@ class GiteaClient:
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -379,7 +381,7 @@ class GiteaClient:
|
||||
}
|
||||
if sha:
|
||||
body["sha"] = sha
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.put(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -395,7 +397,7 @@ class GiteaClient:
|
||||
|
||||
async def delete_file(self, owner, repo, path, sha, message):
|
||||
"""Delete a file from the repo."""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.delete(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -419,7 +421,7 @@ class GiteaClient:
|
||||
cached = self._cached(key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/commits",
|
||||
headers=self._headers,
|
||||
|
||||
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
|
||||
if langs:
|
||||
repo_info["language"] = max(langs, key=langs.get)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("get_repo_info")
|
||||
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Client HTTP partagé (A42 — reliquat du audit).
|
||||
|
||||
51 créations `httpx.AsyncClient(...)` éparpillées dans 15 fichiers = un
|
||||
nouveau pool de connexions par appel (pas de keep-alive). Ici le pool est
|
||||
réutilisé, **closé par boucle d'event** : un `AsyncClient` ne traverse pas
|
||||
de boucle à boucle (les tests en créent une par test → client propre par
|
||||
boucle, collecté avec elle).
|
||||
|
||||
`async with shared_client(timeout=15) as client:` — même syntaxe que
|
||||
before, l'`__aexit__` est un no-op (on ne ferme pas le client partagé).
|
||||
|
||||
ponytail: pas d'`aclose` explicite — le client meurt avec sa boucle
|
||||
(WeakKeyDictionary, les sockets sont fermés par le GC) ; plafond : le pool
|
||||
du loop produit n'est pas fermé à la main. Upgrade si besoin : lifespan
|
||||
qui ferme le client du loop principal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import weakref
|
||||
|
||||
import httpx
|
||||
|
||||
_clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def _hashable(v) -> bool:
|
||||
try:
|
||||
hash(v)
|
||||
return True
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def _key(kwargs: dict) -> tuple:
|
||||
"""Clé de cache = kwargs (timeout/auth/transport/headers…). Valeurs non
|
||||
hashables (dict `headers=`…) → repr, même contrat que la clé."""
|
||||
return tuple(
|
||||
(k, v if _hashable(v) else repr(v))
|
||||
for k, v in sorted(kwargs.items())
|
||||
)
|
||||
|
||||
|
||||
def get_shared_client(**kwargs) -> httpx.AsyncClient:
|
||||
loop = asyncio.get_running_loop()
|
||||
per_loop = _clients.setdefault(loop, {})
|
||||
k = _key(kwargs)
|
||||
client = per_loop.get(k)
|
||||
if client is None:
|
||||
client = httpx.AsyncClient(**kwargs)
|
||||
per_loop[k] = client
|
||||
return client
|
||||
|
||||
|
||||
class shared_client:
|
||||
"""Context manager async : yield du client partagé, no-op à la sortie."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._kwargs = kwargs
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
return get_shared_client(**self._kwargs)
|
||||
|
||||
async def __aexit__(self, *exc) -> bool:
|
||||
return False
|
||||
@@ -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:
|
||||
|
||||
@@ -12,6 +12,7 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
|
||||
@@ -61,7 +62,7 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
|
||||
safe_url = _validate_url(url)
|
||||
result = ImportResult(source="url")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with shared_client(
|
||||
timeout=15, follow_redirects=True, transport=transport,
|
||||
headers={"User-Agent": "FlowDeck-Importer/1.0"},
|
||||
) as client:
|
||||
|
||||
@@ -27,6 +27,7 @@ from dataclasses import dataclass, field
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -270,7 +271,7 @@ class LLMClient:
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
async with shared_client(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
resp = await client.post(self._endpoint(), json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
import time
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
||||
|
||||
# Providers whose /v1/models lists far more entries than /v1/chat/completions
|
||||
@@ -331,7 +332,6 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
|
||||
Returns a de-duplicated list capped at 300 models.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
provider = provider.lower()
|
||||
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
|
||||
@@ -345,7 +345,7 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
elif api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with shared_client(timeout=timeout) as client:
|
||||
resp = await client.get(f"{base_url}/models", headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
body = (resp.text or "").strip()
|
||||
@@ -405,7 +405,6 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
start = time.monotonic()
|
||||
@@ -427,7 +426,7 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
|
||||
return None
|
||||
try:
|
||||
async with sem:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with shared_client(timeout=timeout) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
code = resp.status_code
|
||||
if code < 300 or code == 429:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ import logging
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
|
||||
@@ -143,7 +145,6 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
src = "https://" + src
|
||||
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
|
||||
try:
|
||||
import httpx
|
||||
|
||||
headers = {
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
@@ -152,7 +153,7 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
kwargs = {"timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await _get_checked(client, src, headers)
|
||||
except ValueError:
|
||||
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
|
||||
|
||||
@@ -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")
|
||||
@@ -349,7 +349,7 @@ class RealtimeManager:
|
||||
try:
|
||||
await conn.ws.close(code=4413)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_evict_slow")
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("purge_stale_requests")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -189,7 +190,7 @@ async def fire_event(event: str, payload: dict):
|
||||
if not subs:
|
||||
logger.debug("No subscribers for event: %s", event)
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with shared_client(timeout=30.0) as client:
|
||||
for sub in subs:
|
||||
try:
|
||||
await deliver_to_sub(sub["id"], sub["url"], event,
|
||||
@@ -219,7 +220,7 @@ async def retry_due_deliveries(now: float | None = None) -> int:
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
for r in rows:
|
||||
try:
|
||||
payload = json.loads(r["payload"] or "{}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,172 +15,7 @@
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
// Sur un chargement complet de page, ce script inline s'exécute AVANT
|
||||
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
|
||||
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
|
||||
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
|
||||
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
|
||||
function registerFdCtx() {
|
||||
if (!window.Alpine) return;
|
||||
if (window.Alpine.__fdCtxStore) return;
|
||||
window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
/* Tags (workspace) */
|
||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
||||
newTagColor: '#787774',
|
||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
/* Icon picker */
|
||||
iconOpen: false,
|
||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
||||
|
||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
||||
openMenu(ev, node, pageHash, handlers) {
|
||||
handlers = handlers || {};
|
||||
this.node = node;
|
||||
this.page = pageHash;
|
||||
this.handlers = handlers;
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this._cx = cx;
|
||||
this._cy = cy;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
||||
var after = function () {
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
||||
else setTimeout(after, 0);
|
||||
},
|
||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
||||
_place() {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (!el) return;
|
||||
var prev = el.style.maxHeight;
|
||||
el.style.maxHeight = 'none';
|
||||
var w = el.offsetWidth || 240;
|
||||
var h = el.offsetHeight || 320;
|
||||
el.style.maxHeight = prev || '';
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var cx = (this._cx == null ? this.x : this._cx);
|
||||
var cy = (this._cy == null ? this.y : this._cy);
|
||||
var nx = cx;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
nx = Math.max(8, nx);
|
||||
var ny = cy;
|
||||
if (ny + h > vh - 8) {
|
||||
if (cy - h >= 8) ny = cy - h;
|
||||
else ny = Math.max(8, vh - h - 8);
|
||||
}
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
},
|
||||
|
||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
||||
|
||||
/* Exécute l'action → handler fourni par la page courante. */
|
||||
run(key) {
|
||||
if (!this.node) { this.close(); return; }
|
||||
var fn = this.handlers[key];
|
||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
||||
this.close();
|
||||
},
|
||||
|
||||
/* ── Tags ── */
|
||||
avail() { return this.availTags || []; },
|
||||
setAvail(a) { this.availTags = a || []; },
|
||||
removeTag(id) {
|
||||
var fn = this.handlers.tagRemove;
|
||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
||||
},
|
||||
addExistingTag(t) {
|
||||
var fn = this.handlers.tagExisting;
|
||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
||||
this.tagExistingOpen = false;
|
||||
},
|
||||
addNewTag(name, color) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return;
|
||||
var fn = this.handlers.tagAdd;
|
||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
||||
this.tagAdding = false;
|
||||
},
|
||||
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
var fn = this.handlers.setIcon;
|
||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
||||
this.close();
|
||||
},
|
||||
openIconPicker() {
|
||||
var fn = this.handlers.setIcon;
|
||||
if (!fn) return;
|
||||
if (!window.FDIconPicker) return;
|
||||
window.FDIconPicker.openFor({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
onPick: fn,
|
||||
onRemove: function () { fn(''); }
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Alpine) {
|
||||
registerFdCtx();
|
||||
} else {
|
||||
document.addEventListener('alpine:init', registerFdCtx);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
|
||||
|
||||
<style>
|
||||
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user