Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2339fa2586 | ||
|
|
8b48dbdd4b | ||
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 | ||
|
|
df9a269d76 | ||
|
|
da7326ffde | ||
|
|
3a1276596c | ||
|
|
07904f05e5 | ||
|
|
224bda74d5 | ||
|
|
c718fe06de | ||
|
|
f706424f90 |
+268
@@ -1,5 +1,273 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## 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)
|
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+5
-5
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.5.0 (audit — A29/A42 partiel) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.17.0 (audit — A32 phase 2f : dashboard +7, garde-fous A16) | **Statut**: EN COURS 🔄
|
||||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
+7
-7
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.5.0",
|
version="7.17.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -265,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/manifest.json")
|
@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)."""
|
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/sw.js")
|
@app.get("/sw.js")
|
||||||
async def service_worker():
|
def service_worker():
|
||||||
"""Serve the PWA service worker at top-level scope (/)."""
|
"""Serve the PWA service worker at top-level scope (/)."""
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||||
@@ -282,7 +282,7 @@ async def service_worker():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/csrf-token")
|
@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."""
|
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/pages")
|
@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."""
|
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
qs = str(request.url.query)
|
qs = str(request.url.query)
|
||||||
@@ -306,7 +306,7 @@ async def api_pages_alias(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/pages")
|
@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."""
|
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
return RedirectResponse(url="/board/api/pages", status_code=307)
|
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||||
@@ -341,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
|||||||
|
|
||||||
|
|
||||||
@app.exception_handler(_StarHTTPException)
|
@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.
|
"""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
|
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.templating import CSP_NONCE
|
||||||
|
|
||||||
# ── Constants ────────────────────────────────────────────────
|
# ── Constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
# Allowed extensions for file uploads
|
# Allowed extensions for file uploads
|
||||||
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CSP_HEADER = "Content-Security-Policy"
|
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 = (
|
CSP_VALUE = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
# chart/map de collections — l'upgrade est de les vendoriser dans
|
||||||
|
# /static/js puis de retirer ces deux hôtes.
|
||||||
|
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
|
||||||
|
"https://cdn.jsdelivr.net https://unpkg.com; "
|
||||||
|
"script-src-attr 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
|
||||||
"img-src 'self' data: blob: https:; "
|
"img-src 'self' data: blob: https:; "
|
||||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||||
"connect-src 'self' https: wss: ws:; "
|
"connect-src 'self' https: wss: ws:; "
|
||||||
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
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)
|
response = await call_next(request)
|
||||||
# Only set CSP on HTML responses
|
# Only set CSP on HTML responses
|
||||||
content_type = response.headers.get("content-type", "")
|
content_type = response.headers.get("content-type", "")
|
||||||
if "text/html" in content_type:
|
if "text/html" in content_type:
|
||||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE
|
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+61
-28
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
|
||||||
|
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
|
||||||
|
|
||||||
|
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
|
||||||
|
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
|
||||||
|
unitaire se lit déjà dans le set.
|
||||||
|
"""
|
||||||
|
if not table.replace("_", "").isalnum():
|
||||||
|
raise ValueError(f"nom de table invalide: {table!r}")
|
||||||
|
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
|
||||||
|
|
||||||
def current_version(conn: sqlite3.Connection) -> int:
|
def current_version(conn: sqlite3.Connection) -> int:
|
||||||
_ensure_table(conn)
|
_ensure_table(conn)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
|
|||||||
for version, name, fn in MIGRATIONS:
|
for version, name, fn in MIGRATIONS:
|
||||||
if version <= applied:
|
if version <= applied:
|
||||||
continue
|
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)
|
fn(conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||||
(version, name),
|
(version, name),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
applied = version
|
except BaseException:
|
||||||
logger.info("Applied migration %d: %s", version, name)
|
conn.rollback()
|
||||||
|
raise
|
||||||
return applied
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
|||||||
``projects`` — normalized project list across forges (builtin/gitea/
|
``projects`` — normalized project list across forges (builtin/gitea/
|
||||||
github) + last sync timestamp for the periodic cron.
|
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:
|
if "id" not in _pcols:
|
||||||
conn.execute(
|
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)"
|
"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:
|
if "id" not in _scols:
|
||||||
conn.execute(
|
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)"
|
"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:
|
if "id" not in _projcols:
|
||||||
conn.execute(
|
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)"
|
"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:
|
if "cover_url" not in _pcols:
|
||||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||||
if "page_icon" not in _pcols:
|
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:
|
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||||
and the built-in database templates are seeded (idempotently)."""
|
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:
|
if "icon" not in _cols:
|
||||||
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
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:
|
if "validation_json" not in _pcols:
|
||||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
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
|
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||||
cards), independent from the block-page ``pages.cover_url``.
|
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:
|
if "group_name" not in _pcols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
"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:
|
if "created_by" not in _vcols:
|
||||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||||
if "updated_at" not in _vcols:
|
if "updated_at" not in _vcols:
|
||||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
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:
|
if "cover_url" not in _cpcols:
|
||||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
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)"
|
"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:
|
if "timezone" not in _ucols:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
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
|
``page_global_templates`` — user-created global page templates
|
||||||
(blocks_json = same format as the block editor saves).
|
(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:
|
if "is_locked" not in _pcols:
|
||||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||||
if "locked_by" not in _pcols:
|
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"):
|
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:
|
if "permission_type" not in cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
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:
|
if "permission_type" not in _ccols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
"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:
|
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
"""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:
|
if "sync_version" not in cols:
|
||||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
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.
|
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||||
"""
|
"""
|
||||||
# api_tokens extra columns
|
# 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:
|
if "scopes" not in _cols:
|
||||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||||
if "expires_at" not in _cols:
|
if "expires_at" not in _cols:
|
||||||
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
|||||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||||
``superseded`` (a retry row replaced this attempt).
|
``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:
|
if "event" not in _cols:
|
||||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||||
if "next_retry_at" not in _cols:
|
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
|
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
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:
|
if "collection_row_id" not in cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||||
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
|
"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:
|
if "form_config_json" not in cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
|
"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:
|
if "search_excluded" not in cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
|
"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 "
|
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
|
||||||
"ON worker_runs(worker_id, created_at)"
|
"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:
|
if "trigger_mode" not in auto_cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
"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:
|
if "button_automation_id" not in prop_cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
|
"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 "
|
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
|
||||||
"ON meeting_transcripts(page_id)"
|
"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:
|
if "external_event_id" not in cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
|
"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 "
|
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
|
||||||
"ON agent_approvals(status, created_at)"
|
"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:
|
if "totp_secret_enc" not in user_cols:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
||||||
if "totp_backup_hashes" not in user_cols:
|
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"):
|
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:
|
if "teamspace_id" not in cols:
|
||||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
|
|||||||
|
|
||||||
# ── Users ──
|
# ── Users ──
|
||||||
@router.get("/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."""
|
"""List all users with workspace/file/folder counts and storage usage."""
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -109,7 +109,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id:int}")
|
@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."""
|
"""Delete a user and cascade their data."""
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
|||||||
|
|
||||||
# ── Stats ──
|
# ── Stats ──
|
||||||
@router.get("/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."""
|
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
|
|||||||
|
|
||||||
# ── Audit ──
|
# ── Audit ──
|
||||||
@router.get("/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."""
|
"""Recent login history."""
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+10
-10
@@ -213,7 +213,7 @@ async def create_conversation(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/conversations/{conversation_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
||||||
if not conv:
|
if not conv:
|
||||||
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/conversations/{conversation_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
|
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
|
||||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||||
@@ -450,7 +450,7 @@ async def agent_writing_properties(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/conversations/{conversation_id}/actions")
|
@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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||||
@@ -460,7 +460,7 @@ async def list_actions(request: Request, conversation_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/actions/{action_id}/undo")
|
@router.post("/actions/{action_id}/undo")
|
||||||
async def undo(request: Request, action_id: int):
|
def undo(request: Request, action_id: int):
|
||||||
try:
|
try:
|
||||||
undo_action(action_id)
|
undo_action(action_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/gallery")
|
@router.get("/skills/gallery")
|
||||||
async def skills_gallery(request: Request):
|
def skills_gallery(request: Request):
|
||||||
presets = skill_gallery.list_gallery()
|
presets = skill_gallery.list_gallery()
|
||||||
return {"gallery": presets, "total": len(presets),
|
return {"gallery": presets, "total": len(presets),
|
||||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||||
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/{skill_id}/export")
|
@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."""
|
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||||
@@ -587,7 +587,7 @@ async def export_skill(request: Request, skill_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/skills/{skill_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/tools")
|
@router.get("/tools")
|
||||||
async def list_tools(request: Request):
|
def list_tools(request: Request):
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
tools = registry.schema()
|
tools = registry.schema()
|
||||||
return {"tools": tools}
|
return {"tools": tools}
|
||||||
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{agent_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{agent_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|||||||
+14
-14
@@ -83,7 +83,7 @@ async def health(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def stats():
|
def stats():
|
||||||
"""Global stats for dashboard."""
|
"""Global stats for dashboard."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
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")
|
@router.post("/col-mapping")
|
||||||
async def set_col_mapping(
|
def set_col_mapping(
|
||||||
owner: str = Query(...),
|
owner: str = Query(...),
|
||||||
repo: str = Query(...),
|
repo: str = Query(...),
|
||||||
column: str = Query(...),
|
column: str = Query(...),
|
||||||
@@ -209,7 +209,7 @@ async def set_col_mapping(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/col-mapping")
|
@router.delete("/col-mapping")
|
||||||
async def delete_col_mapping(
|
def delete_col_mapping(
|
||||||
owner: str = Query(...),
|
owner: str = Query(...),
|
||||||
repo: str = Query(...),
|
repo: str = Query(...),
|
||||||
column: str = Query(...),
|
column: str = Query(...),
|
||||||
@@ -234,7 +234,7 @@ async def delete_col_mapping(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/board-config/{owner}/{repo}")
|
@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:
|
with get_conn() as conn:
|
||||||
board = conn.execute(
|
board = conn.execute(
|
||||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
"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}")
|
@router.post("/board-config/{owner}/{repo}")
|
||||||
async def update_board_config(
|
def update_board_config(
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
columns: str = Query(default=""),
|
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 ──
|
# ── v0.5.0: Checklists ──
|
||||||
|
|
||||||
@router.post("/checklists/{owner}/{repo}/{issue_id}")
|
@router.post("/checklists/{owner}/{repo}/{issue_id}")
|
||||||
async def create_checklist(
|
def create_checklist(
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
issue_id: int,
|
issue_id: int,
|
||||||
@@ -484,7 +484,7 @@ async def create_checklist(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
|
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
|
||||||
async def add_checklist_item(
|
def add_checklist_item(
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
issue_id: int,
|
issue_id: int,
|
||||||
@@ -502,7 +502,7 @@ async def add_checklist_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/checklist-items/{item_id}")
|
@router.patch("/checklist-items/{item_id}")
|
||||||
async def toggle_checklist_item(
|
def toggle_checklist_item(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
checked: bool = Query(default=False),
|
checked: bool = Query(default=False),
|
||||||
content: str = Query(default=""),
|
content: str = Query(default=""),
|
||||||
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/checklist-items/{item_id}")
|
@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."""
|
"""Delete a checklist item."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
|
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}")
|
@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."""
|
"""Delete a checklist and all its items."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
|
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
|
||||||
@@ -545,7 +545,7 @@ async def delete_checklist(checklist_id: int):
|
|||||||
# ── v1.0.0: User management ──
|
# ── v1.0.0: User management ──
|
||||||
|
|
||||||
@router.get("/users/me")
|
@router.get("/users/me")
|
||||||
async def get_my_profile(request: Request):
|
def get_my_profile(request: Request):
|
||||||
"""Get current user profile."""
|
"""Get current user profile."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -565,7 +565,7 @@ async def get_my_profile(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/users/me")
|
@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="")):
|
email: str = Query(default="")):
|
||||||
"""Update current user's local profile."""
|
"""Update current user's local profile."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
@@ -584,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
|
|||||||
# ── v0.5.0: Card priority & due date ──
|
# ── v0.5.0: Card priority & due date ──
|
||||||
|
|
||||||
@router.post("/card/{owner}/{repo}/{issue_id}")
|
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||||
async def update_card(
|
def update_card(
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
issue_id: int,
|
issue_id: int,
|
||||||
@@ -673,7 +673,7 @@ async def capture_frontend_error(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/frontend-errors")
|
@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."""
|
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
||||||
errors = list(_frontend_errors)
|
errors = list(_frontend_errors)
|
||||||
if clear:
|
if clear:
|
||||||
|
|||||||
+130
-317
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
|
|||||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/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)
|
user = _guard(request, authorization)
|
||||||
limit, offset = parse_pagination(request)
|
limit, offset = parse_pagination(request)
|
||||||
ws = _workspace_of(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}")
|
@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)
|
_guard(request, authorization)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
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}")
|
@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)
|
user = _guard(request, authorization, write=True)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
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}) ────────
|
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||||
|
|
||||||
@router.get("/agents/conversations")
|
@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)
|
user = _guard(request, authorization)
|
||||||
limit, offset = parse_pagination(request)
|
limit, offset = parse_pagination(request)
|
||||||
with get_conn() as conn:
|
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}")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization)
|
user = _guard(request, authorization)
|
||||||
with get_conn() as conn:
|
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}")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization, write=True)
|
user = _guard(request, authorization, write=True)
|
||||||
with get_conn() as conn:
|
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")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization)
|
user = _guard(request, authorization)
|
||||||
with get_conn() as conn:
|
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")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization, write=True)
|
user = _guard(request, authorization, write=True)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
|
|||||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/skills")
|
@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)
|
_guard(request, authorization)
|
||||||
limit, offset = parse_pagination(request)
|
limit, offset = parse_pagination(request)
|
||||||
ws = _workspace_of(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
|
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||||
@router.get("/skills/gallery")
|
@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)
|
_guard(request, authorization)
|
||||||
presets = skill_gallery.list_gallery()
|
presets = skill_gallery.list_gallery()
|
||||||
return {"gallery": presets, "total": len(presets),
|
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}")
|
@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)
|
_guard(request, authorization)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
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")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||||
_guard(request, authorization)
|
_guard(request, authorization)
|
||||||
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/skills/{skill_id}")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization, write=True)
|
user = _guard(request, authorization, write=True)
|
||||||
with get_conn() as conn:
|
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")
|
@router.get("/api/v2/audit/logs")
|
||||||
async def audit_logs(request: Request):
|
def audit_logs(request: Request):
|
||||||
_admin_user(request)
|
_admin_user(request)
|
||||||
qp = request.query_params
|
qp = request.query_params
|
||||||
source = (qp.get("source") or "all").lower()
|
source = (qp.get("source") or "all").lower()
|
||||||
|
|||||||
+19
-9
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.templating import CSP_NONCE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
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")
|
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||||
return f"{scheme}://{host}/auth/callback"
|
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>
|
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/register")
|
@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)."""
|
"""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 active" onclick="switchTab(\'login\')"',
|
||||||
'class="tab" onclick="switchTab(\'login\')"'
|
'class="tab" onclick="switchTab(\'login\')"'
|
||||||
).replace(
|
).replace(
|
||||||
@@ -163,16 +173,16 @@ async def register_page(request: Request):
|
|||||||
).replace(
|
).replace(
|
||||||
'id="submit-btn">Login<',
|
'id="submit-btn">Login<',
|
||||||
'id="submit-btn">Register<'
|
'id="submit-btn">Register<'
|
||||||
), status_code=200)
|
)), status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@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."""
|
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||||
# Local login page (POST handled by /auth/local-login)
|
# Local login page (POST handled by /auth/local-login)
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
if provider == "local":
|
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
|
# OAuth flow — check if provider is configured
|
||||||
from app.auth.providers import get_provider
|
from app.auth.providers import get_provider
|
||||||
@@ -450,7 +460,7 @@ async def callback(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
async def logout(request: Request):
|
def logout(request: Request):
|
||||||
"""Clear session and redirect to login page.
|
"""Clear session and redirect to login page.
|
||||||
|
|
||||||
SAML sessions additionally hand over to the IdP's Single Logout when one
|
SAML sessions additionally hand over to the IdP's Single Logout when one
|
||||||
@@ -519,7 +529,7 @@ def _session_user_or_401(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/2fa/status")
|
@router.get("/2fa/status")
|
||||||
async def twofa_status(request: Request):
|
def twofa_status(request: Request):
|
||||||
from app.services import two_factor as _2fa
|
from app.services import two_factor as _2fa
|
||||||
user = _session_user_or_401(request)
|
user = _session_user_or_401(request)
|
||||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||||
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/2fa/setup")
|
@router.post("/2fa/setup")
|
||||||
async def twofa_setup(request: Request):
|
def twofa_setup(request: Request):
|
||||||
from app.services import two_factor as _2fa
|
from app.services import two_factor as _2fa
|
||||||
user = _session_user_or_401(request)
|
user = _session_user_or_401(request)
|
||||||
return _2fa.setup_secret(user["id"])
|
return _2fa.setup_secret(user["id"])
|
||||||
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/2fa/disable")
|
@router.post("/2fa/disable")
|
||||||
async def twofa_disable(request: Request):
|
def twofa_disable(request: Request):
|
||||||
from app.services import two_factor as _2fa
|
from app.services import two_factor as _2fa
|
||||||
user = _session_user_or_401(request)
|
user = _session_user_or_401(request)
|
||||||
_2fa.disable(user["id"])
|
_2fa.disable(user["id"])
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/workspace/automations")
|
@router.get("/workspace/automations")
|
||||||
async def list_automations(request: Request):
|
def list_automations(request: Request):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
||||||
items = [dict(r) for r in rows]
|
items = [dict(r) for r in rows]
|
||||||
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/workspace/automations/{auto_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/workspace/automations/{auto_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/workspace/automations/{auto_id}/runs")
|
@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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT * FROM automation_runs WHERE automation_id=?
|
"""SELECT * FROM automation_runs WHERE automation_id=?
|
||||||
@@ -226,7 +226,7 @@ def _encrypt_step_config(config: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/workspace/automations/{auto_id}/steps")
|
@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:
|
if _get_auto(auto_id) is None:
|
||||||
return _auto_404()
|
return _auto_404()
|
||||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||||
@@ -279,7 +279,7 @@ async def update_step(request: Request, step_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/workspace/automations/steps/{step_id}")
|
@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)
|
_require_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||||
|
|||||||
+23
-23
@@ -66,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/wiki/pages")
|
@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
|
"""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)."""
|
every non-deleted page the current user can see (single source: pages)."""
|
||||||
q = (q or "").strip().lower()
|
q = (q or "").strip().lower()
|
||||||
@@ -98,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/wiki/titles")
|
@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)."""
|
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||||
parsed: list[int] = []
|
parsed: list[int] = []
|
||||||
for part in (ids or "").split(","):
|
for part in (ids or "").split(","):
|
||||||
@@ -179,7 +179,7 @@ async def set_page_options(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/page-templates")
|
@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."""
|
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
uid = (user or {}).get("id")
|
uid = (user or {}).get("id")
|
||||||
@@ -899,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
|||||||
# ═══════════ Library page ═══════════
|
# ═══════════ Library page ═══════════
|
||||||
|
|
||||||
@router.get("/library", response_class=HTMLResponse)
|
@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."""
|
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -943,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
|||||||
# ═══════════ Favorites API ═══════════
|
# ═══════════ Favorites API ═══════════
|
||||||
|
|
||||||
@router.get("/api/favorites")
|
@router.get("/api/favorites")
|
||||||
async def list_favorites(request: Request):
|
def list_favorites(request: Request):
|
||||||
"""List favorited page IDs for the current user."""
|
"""List favorited page IDs for the current user."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
uid = user["id"] if user and user.get("id") else 1
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
@@ -1033,7 +1033,7 @@ async def unpublish_page(request: Request, page_id: int):
|
|||||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||||
|
|
||||||
@router.get("/api/trash")
|
@router.get("/api/trash")
|
||||||
async def list_trash(request: Request):
|
def list_trash(request: Request):
|
||||||
with get_conn() as conn:
|
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()
|
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]
|
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||||
@@ -1052,7 +1052,7 @@ async def restore_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/trash/{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:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
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,))
|
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||||
@@ -1061,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/trash", response_class=HTMLResponse)
|
@router.get("/trash", response_class=HTMLResponse)
|
||||||
async def trash_page(request: Request):
|
def trash_page(request: Request):
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
template = env.get_template("trash.html")
|
template = env.get_template("trash.html")
|
||||||
@@ -1072,7 +1072,7 @@ async def trash_page(request: Request):
|
|||||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||||
|
|
||||||
@router.get("/api/synced-blocks")
|
@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."""
|
"""List synced blocks for a workspace."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
from app.services.synced_blocks import list_synced_blocks
|
from app.services.synced_blocks import list_synced_blocks
|
||||||
@@ -1147,7 +1147,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/synced-blocks/{sid}")
|
@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."""
|
"""Get a synced block by id."""
|
||||||
from app.services.synced_blocks import get_synced_block
|
from app.services.synced_blocks import get_synced_block
|
||||||
sb = get_synced_block(sid)
|
sb = get_synced_block(sid)
|
||||||
@@ -1173,7 +1173,7 @@ async def add_synced_to_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
@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)."""
|
"""Remove a synced block reference from a page (unsync)."""
|
||||||
from app.services.synced_blocks import remove_page_synced
|
from app.services.synced_blocks import remove_page_synced
|
||||||
remove_page_synced(page_id, sid)
|
remove_page_synced(page_id, sid)
|
||||||
@@ -1181,7 +1181,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}/synced")
|
@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."""
|
"""Get all synced block references for a page."""
|
||||||
from app.services.synced_blocks import get_page_synced
|
from app.services.synced_blocks import get_page_synced
|
||||||
return {"synced_blocks": get_page_synced(page_id)}
|
return {"synced_blocks": get_page_synced(page_id)}
|
||||||
@@ -1190,7 +1190,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
|||||||
# ═══════════ Board page ═══════════
|
# ═══════════ Board page ═══════════
|
||||||
|
|
||||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
@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
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, owner, repo)
|
sidebar = _sidebar_data(request, owner, repo)
|
||||||
@@ -1279,12 +1279,12 @@ async def board_view(
|
|||||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||||
|
|
||||||
@router.get("/api/properties/{owner}/{repo}")
|
@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)}
|
return {"properties": _get_project_properties(owner, repo)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/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"),
|
prop_type: str = Query(default="select"),
|
||||||
options: str = Query(default="")):
|
options: str = Query(default="")):
|
||||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||||
@@ -1301,7 +1301,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/properties/{owner}/{repo}")
|
@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:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||||
@@ -1312,7 +1312,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/properties/{owner}/{repo}/values")
|
@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="")):
|
name: str = Query(...), value: str = Query(default="")):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
prop = conn.execute(
|
prop = conn.execute(
|
||||||
@@ -1332,7 +1332,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
|||||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||||
|
|
||||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
@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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||||
@@ -1395,7 +1395,7 @@ async def create_page(request: Request, title: str = Query(default=""),
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}")
|
@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."""
|
"""Get a Markdown page."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user or not user.get("id"):
|
if not user or not user.get("id"):
|
||||||
@@ -1543,7 +1543,7 @@ def _block_texts(b: dict) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}/backlinks")
|
@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…").
|
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||||
|
|
||||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||||
@@ -1591,7 +1591,7 @@ async def page_backlinks(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}/versions")
|
@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."""
|
"""v5.4.0: version history for a block-editor page."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -1750,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/pages/{page_id}/cover")
|
@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:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -2024,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
@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.
|
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||||
embed = request.query_params.get("embed") == "1"
|
embed = request.query_params.get("embed") == "1"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def _serialize(rows):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/comments")
|
@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."""
|
"""List page-level and inline comments for a FlowDeck page."""
|
||||||
_current_user(request)
|
_current_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/comments/{comment_id}")
|
@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."""
|
"""Delete a comment and its replies."""
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+29
-28
@@ -27,6 +27,7 @@ from app.services.recurrence import (
|
|||||||
validate_rule,
|
validate_rule,
|
||||||
)
|
)
|
||||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||||
|
from app.templating import CSP_NONCE
|
||||||
|
|
||||||
|
|
||||||
def _current_user(request: Request) -> dict:
|
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)
|
@router.get("", response_class=HTMLResponse)
|
||||||
async def list_collections(request: Request):
|
def list_collections(request: Request):
|
||||||
"""Page listing all collections in the workspace."""
|
"""Page listing all collections in the workspace."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api")
|
@router.get("/api")
|
||||||
async def list_collections_api(request: Request):
|
def list_collections_api(request: Request):
|
||||||
"""API: list all collections."""
|
"""API: list all collections."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/api")
|
@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."""
|
"""API: get a single page."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
page = conn.execute(
|
page = conn.execute(
|
||||||
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/open/api")
|
@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).
|
"""v6.5.0 — content page of a database row (lazy-created).
|
||||||
|
|
||||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||||
@@ -649,7 +650,7 @@ async def delete_page_api(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/boards/api")
|
@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."""
|
"""API: list all Gitea boards as pseudo-collections."""
|
||||||
from app.services.collection_adapter import GiteaBoardCompat
|
from app.services.collection_adapter import GiteaBoardCompat
|
||||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||||
@@ -689,14 +690,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/property-types/api")
|
@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."""
|
"""API: list all available property types."""
|
||||||
from app.services.property_types import PROPERTY_TYPES
|
from app.services.property_types import PROPERTY_TYPES
|
||||||
return {"types": PROPERTY_TYPES}
|
return {"types": PROPERTY_TYPES}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/properties/api")
|
@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."""
|
"""API: list all properties visible to the current user."""
|
||||||
user = _session_user(request)
|
user = _session_user(request)
|
||||||
_require_view(collection_id, user)
|
_require_view(collection_id, user)
|
||||||
@@ -719,7 +720,7 @@ async def list_properties_api(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/members/api")
|
@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.
|
"""API: list workspace members available for a ``person`` property.
|
||||||
|
|
||||||
Resolves the collection's workspace and returns its members (falling back to
|
Resolves the collection's workspace and returns its members (falling back to
|
||||||
@@ -752,7 +753,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/calendar/api")
|
@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 = "",
|
start: str = "", end: str = "",
|
||||||
date_property: str = ""):
|
date_property: str = ""):
|
||||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||||
@@ -837,7 +838,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/timezones/api")
|
@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."""
|
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -972,7 +973,7 @@ async def update_property_api(request: Request, prop_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/properties/{prop_id}/api")
|
@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."""
|
"""API: delete a property."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/views/{view_id}/api")
|
@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."""
|
"""API: get a single view config."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||||
@@ -1251,7 +1252,7 @@ async def save_view_as(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/views/api")
|
@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.
|
"""API: list views for a collection visible to the current user.
|
||||||
|
|
||||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||||
@@ -1276,7 +1277,7 @@ async def list_views_api(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/views/{view_id}/api")
|
@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."""
|
"""API: delete a saved view."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||||
@@ -1332,7 +1333,7 @@ async def duplicate_view_api(request: Request, view_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
@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."""
|
"""API: list sub-items of a page."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -1395,7 +1396,7 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
@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."""
|
"""API: compute aggregate status from children."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
children = conn.execute(
|
children = conn.execute(
|
||||||
@@ -1494,7 +1495,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/sources/api")
|
@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."""
|
"""API: list all data sources for a collection."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
@@ -1554,7 +1555,7 @@ async def add_data_source(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{collection_id}/sources/{source_id}/api")
|
@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."""
|
"""API: remove a data source from a collection."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{collection_id}/toggle-inline/api")
|
@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."""
|
"""API: toggle a collection between full-page and inline mode."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
coll = conn.execute(
|
coll = conn.execute(
|
||||||
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{collection_id}/toggle-task/api")
|
@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)."""
|
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
@@ -1771,7 +1772,7 @@ async def toggle_task(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
|
@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)."""
|
"""API: list dependencies for a page (blocks, blocked_by, related)."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -1819,7 +1820,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")
|
@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."""
|
"""API: remove a dependency."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
@@ -1914,7 +1915,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
@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).
|
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||||
|
|
||||||
Widgets live in ``collection_dashboards.layout_json`` as
|
Widgets live in ``collection_dashboards.layout_json`` as
|
||||||
@@ -1980,7 +1981,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
|
|||||||
|
|
||||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||||
@router.get("/{collection_id}/view/{view_type}", 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."""
|
"""Main view — renders collection in the requested view type."""
|
||||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||||
_require_view(collection_id, _session_user(request))
|
_require_view(collection_id, _session_user(request))
|
||||||
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
|
|||||||
</style>
|
</style>
|
||||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
<script>
|
<script nonce="{CSP_NONCE.get()}">
|
||||||
new Chart(document.getElementById('chartCanvas'), {{
|
new Chart(document.getElementById('chartCanvas'), {{
|
||||||
type: '{chart_type}',
|
type: '{chart_type}',
|
||||||
data: {{
|
data: {{
|
||||||
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
|
|||||||
</form>
|
</form>
|
||||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script nonce="{CSP_NONCE.get()}">
|
||||||
async function submitForm(e) {{
|
async function submitForm(e) {{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = document.getElementById('collectionForm');
|
const form = document.getElementById('collectionForm');
|
||||||
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
|||||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
||||||
<script>
|
<script nonce="{CSP_NONCE.get()}">
|
||||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
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);
|
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||||
const markers = {markers_json};
|
const markers = {markers_json};
|
||||||
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/api")
|
@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."""
|
"""API: get a single collection with its pages."""
|
||||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||||
_require_view(collection_id, _session_user(request))
|
_require_view(collection_id, _session_user(request))
|
||||||
|
|||||||
+40
-40
@@ -252,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/trash", response_class=HTMLResponse)
|
@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."""
|
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||||
from app.routers.board import _sidebar_data as board_sidebar
|
from app.routers.board import _sidebar_data as board_sidebar
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
@@ -279,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/library", response_class=HTMLResponse)
|
@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).
|
"""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.
|
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||||
@@ -308,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
@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.
|
"""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=1 — minimal mode for side peek (editor only, no header)."""
|
||||||
embed = request.query_params.get("embed") == "1"
|
embed = request.query_params.get("embed") == "1"
|
||||||
@@ -434,7 +434,7 @@ async def view_page_root(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/accounts", response_class=HTMLResponse)
|
@router.get("/accounts", response_class=HTMLResponse)
|
||||||
async def accounts_page(request: Request):
|
def accounts_page(request: Request):
|
||||||
"""Account management panel."""
|
"""Account management panel."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -451,7 +451,7 @@ async def accounts_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/help", response_class=HTMLResponse)
|
@router.get("/help", response_class=HTMLResponse)
|
||||||
async def help_page(request: Request):
|
def help_page(request: Request):
|
||||||
"""Comprehensive help & documentation page."""
|
"""Comprehensive help & documentation page."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -656,7 +656,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||||
async def settings_page(request: Request):
|
def settings_page(request: Request):
|
||||||
"""User settings page — profile, forges, tokens."""
|
"""User settings page — profile, forges, tokens."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -731,7 +731,7 @@ async def update_password(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/user/token")
|
@router.post("/api/user/token")
|
||||||
async def generate_token(request: Request):
|
def generate_token(request: Request):
|
||||||
import secrets
|
import secrets
|
||||||
uid = _require_user_id(request)
|
uid = _require_user_id(request)
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
@@ -745,7 +745,7 @@ async def generate_token(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/user/forge/{provider}")
|
@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)
|
uid = _require_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -834,7 +834,7 @@ async def dashboard(
|
|||||||
# ═══════════ Workspace ═══════════
|
# ═══════════ Workspace ═══════════
|
||||||
|
|
||||||
@router.get("/workspace", response_class=HTMLResponse)
|
@router.get("/workspace", response_class=HTMLResponse)
|
||||||
async def workspace_page(request: Request):
|
def workspace_page(request: Request):
|
||||||
"""Unified workspace showing all projects."""
|
"""Unified workspace showing all projects."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -851,7 +851,7 @@ async def workspace_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
@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."""
|
"""Gitea workspace — browse repo files."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -971,7 +971,7 @@ async def create_workspace_project(request: Request):
|
|||||||
# ═══════════ Workspace Members API ═══════════
|
# ═══════════ Workspace Members API ═══════════
|
||||||
|
|
||||||
@router.get("/api/workspace/{ws_id:int}/members")
|
@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."""
|
"""List all members of a workspace."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -1022,7 +1022,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}")
|
@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."""
|
"""Remove a member from a workspace."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1036,7 +1036,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
|||||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||||
|
|
||||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
@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.
|
"""Local workspace page with file/folder tree.
|
||||||
|
|
||||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||||
@@ -1084,7 +1084,7 @@ async def local_workspace_page(request: Request, folder: int = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/local-workspace/tree")
|
@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.
|
"""Return the file/folder tree filtered by active workspace.
|
||||||
|
|
||||||
If ?folder=ID is provided, returns only that folder's children.
|
If ?folder=ID is provided, returns only that folder's children.
|
||||||
@@ -1109,7 +1109,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
@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)."""
|
"""Return the raw content of a page (for preview)."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -1174,7 +1174,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}/download")
|
@router.get("/api/pages/{page_id}/download")
|
||||||
async def download_page_file(request: Request, page_id: int):
|
def download_page_file(request: Request, page_id: int):
|
||||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||||
_require_page_view(request, page_id)
|
_require_page_view(request, page_id)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -1197,7 +1197,7 @@ async def download_page_file(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id}/file-content")
|
@router.get("/api/pages/{page_id}/file-content")
|
||||||
async def page_file_content(request: Request, page_id: int):
|
def page_file_content(request: Request, page_id: int):
|
||||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||||
|
|
||||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||||
@@ -1225,7 +1225,7 @@ async def page_file_content(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/local-workspace/breadcrumb")
|
@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."""
|
"""Return breadcrumb trail for a folder."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
breadcrumb = _build_breadcrumb(conn, folder)
|
breadcrumb = _build_breadcrumb(conn, folder)
|
||||||
@@ -1393,7 +1393,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/nav/menu")
|
@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.
|
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||||
|
|
||||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||||
@@ -1483,7 +1483,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/local-workspace/items/{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)."""
|
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -1496,7 +1496,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
@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."""
|
"""Restore a soft-deleted file/folder."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1508,7 +1508,7 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
@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."""
|
"""Serve an uploaded file from disk."""
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -1774,7 +1774,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/workspaces", response_class=HTMLResponse)
|
@router.get("/workspaces", response_class=HTMLResponse)
|
||||||
async def workspaces_page(request: Request):
|
def workspaces_page(request: Request):
|
||||||
"""Workspaces list page."""
|
"""Workspaces list page."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -1791,7 +1791,7 @@ async def workspaces_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/workspaces")
|
@router.get("/api/workspaces")
|
||||||
async def list_workspaces(request: Request):
|
def list_workspaces(request: Request):
|
||||||
"""List all workspaces for the current user."""
|
"""List all workspaces for the current user."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
uid = user["id"] if user and user.get("id") else 1
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
@@ -1855,7 +1855,7 @@ async def rename_workspace(request: Request, ws_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/workspaces/{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."""
|
"""Delete a workspace and all its pages."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||||
@@ -1866,7 +1866,7 @@ async def delete_workspace(request: Request, ws_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
@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."""
|
"""Set the active workspace via cookie."""
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||||
@@ -1877,7 +1877,7 @@ async def select_workspace(request: Request, ws_id: int):
|
|||||||
# ═══════════ Settings Page ═══════════
|
# ═══════════ Settings Page ═══════════
|
||||||
|
|
||||||
@router.get("/settings", response_class=HTMLResponse)
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
async def app_settings_page(request: Request):
|
def app_settings_page(request: Request):
|
||||||
"""Settings & configuration page."""
|
"""Settings & configuration page."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -1923,7 +1923,7 @@ async def upload_avatar(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/settings/avatar/{filename:path}")
|
@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."""
|
"""Serve an uploaded avatar image file."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -1942,7 +1942,7 @@ async def serve_avatar_file(filename: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/avatar/{user_id:int}")
|
@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."""
|
"""Redirect to the user's avatar."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
@@ -2002,7 +2002,7 @@ async def update_tag_global(tag_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
@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."""
|
"""Delete a tag — only if owned by user."""
|
||||||
uid = _get_user_id(request)
|
uid = _get_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -2013,7 +2013,7 @@ async def delete_tag_global(tag_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/settings/tags/all")
|
@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."""
|
"""List current user's tags with counts."""
|
||||||
uid = _get_user_id(request)
|
uid = _get_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -2032,7 +2032,7 @@ async def list_all_tags_global(request: Request):
|
|||||||
# ═══════════ Workspace Tags API ═══════════
|
# ═══════════ Workspace Tags API ═══════════
|
||||||
|
|
||||||
@router.get("/api/local-workspace/tags")
|
@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."""
|
"""List ALL user tags with counts scoped to the active workspace."""
|
||||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||||
ws_id = ws["id"] if ws else None
|
ws_id = ws["id"] if ws else None
|
||||||
@@ -2055,7 +2055,7 @@ async def list_tags(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
@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."""
|
"""Get tags for a specific item."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -2104,7 +2104,7 @@ async def add_item_tag(request: Request, item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
@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."""
|
"""Remove a tag from an item."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -2116,7 +2116,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/local-workspace/tags/search")
|
@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)."""
|
"""Search items by tags (comma-separated)."""
|
||||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||||
ws_id = ws["id"] if ws else None
|
ws_id = ws["id"] if ws else None
|
||||||
@@ -2198,7 +2198,7 @@ async def update_account(request: Request):
|
|||||||
# ═══════════ Sidebar Refresh API ═══════════
|
# ═══════════ Sidebar Refresh API ═══════════
|
||||||
|
|
||||||
@router.get("/api/sidebar/workspace-tree")
|
@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.
|
"""Return the sidebar workspace tree as HTML fragment.
|
||||||
|
|
||||||
Called by appState().refreshSidebarTree() after CRUD operations
|
Called by appState().refreshSidebarTree() after CRUD operations
|
||||||
@@ -2260,7 +2260,7 @@ async def sidebar_workspace_tree(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
@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."""
|
"""Serve a published page at /p/<slug> — no auth required."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
|
|
||||||
@@ -2525,7 +2525,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
|||||||
# ═══════════ Library page actions API ═══════════
|
# ═══════════ Library page actions API ═══════════
|
||||||
|
|
||||||
@router.get("/api/pages/{page_id:int}/content")
|
@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."""
|
"""Get page content for side peek preview."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -2563,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/pages/{page_id:int}/trash")
|
@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)."""
|
"""Soft-delete a page (move to trash)."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -2641,7 +2641,7 @@ async def api_convert_to_database(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
@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."""
|
"""Get collection properties + pages for rendering the table view."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
coll = conn.execute(
|
coll = conn.execute(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/custom-emojis")
|
@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."""
|
"""List the current workspace's custom emojis."""
|
||||||
ws_id = _active_ws(request)
|
ws_id = _active_ws(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
@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)."""
|
"""Delete a custom emoji (and its stored file)."""
|
||||||
ws_id = _active_ws(request)
|
ws_id = _active_ws(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def _safe_filename(page: dict, ext: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/markdown/{page_id}")
|
@router.get("/markdown/{page_id}")
|
||||||
async def export_markdown(page_id: int, request: Request):
|
def export_markdown(page_id: int, request: Request):
|
||||||
page = _load_page_or_404(request, page_id)
|
page = _load_page_or_404(request, page_id)
|
||||||
md = page_to_markdown(page)
|
md = page_to_markdown(page)
|
||||||
filename = _safe_filename(page, "md")
|
filename = _safe_filename(page, "md")
|
||||||
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/html/{page_id}")
|
@router.get("/html/{page_id}")
|
||||||
async def export_html(page_id: int, request: Request):
|
def export_html(page_id: int, request: Request):
|
||||||
page = _load_page_or_404(request, page_id)
|
page = _load_page_or_404(request, page_id)
|
||||||
html = page_to_standalone_html(page)
|
html = page_to_standalone_html(page)
|
||||||
filename = _safe_filename(page, "html")
|
filename = _safe_filename(page, "html")
|
||||||
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pdf/{page_id}")
|
@router.get("/pdf/{page_id}")
|
||||||
async def export_pdf(page_id: int, request: Request):
|
def export_pdf(page_id: int, request: Request):
|
||||||
page = _load_page_or_404(request, page_id)
|
page = _load_page_or_404(request, page_id)
|
||||||
try:
|
try:
|
||||||
pdf_bytes = page_to_pdf_bytes(page)
|
pdf_bytes = page_to_pdf_bytes(page)
|
||||||
@@ -94,7 +94,7 @@ async def export_pdf(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/site/{page_id}")
|
@router.get("/site/{page_id}")
|
||||||
async def export_site(page_id: int, request: Request):
|
def export_site(page_id: int, request: Request):
|
||||||
page = _load_page_or_404(request, page_id)
|
page = _load_page_or_404(request, page_id)
|
||||||
site_bytes = build_static_site_bytes(page)
|
site_bytes = build_static_site_bytes(page)
|
||||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
|
|||||||
# ── Account linking ──
|
# ── Account linking ──
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def gitea_status(request: Request):
|
def gitea_status(request: Request):
|
||||||
"""Check if the current user has Gitea linked."""
|
"""Check if the current user has Gitea linked."""
|
||||||
from app.services.gitea_client import get_user_gitea_client
|
from app.services.gitea_client import get_user_gitea_client
|
||||||
client = get_user_gitea_client(request)
|
client = get_user_gitea_client(request)
|
||||||
@@ -170,7 +170,7 @@ async def gitea_status(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/disconnect")
|
@router.delete("/disconnect")
|
||||||
async def disconnect_gitea(request: Request):
|
def disconnect_gitea(request: Request):
|
||||||
"""Remove all Gitea OAuth tokens for the current user."""
|
"""Remove all Gitea OAuth tokens for the current user."""
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -186,7 +186,7 @@ async def disconnect_gitea(request: Request):
|
|||||||
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||||
|
|
||||||
@router.get("/projects/{owner}/{repo}/private-pages")
|
@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."""
|
"""List private pages for this Gitea project."""
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -225,7 +225,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
@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."""
|
"""Get a single private page."""
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -272,7 +272,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
@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."""
|
"""Delete a private page."""
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def github_status(request: Request):
|
def github_status(request: Request):
|
||||||
"""Check if the current user has GitHub linked."""
|
"""Check if the current user has GitHub linked."""
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -22,7 +22,7 @@ async def github_status(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/disconnect")
|
@router.delete("/disconnect")
|
||||||
async def disconnect_github(request: Request):
|
def disconnect_github(request: Request):
|
||||||
"""Remove all GitHub OAuth tokens for the current user."""
|
"""Remove all GitHub OAuth tokens for the current user."""
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/agent-policies")
|
@router.get("/api/v2/agent-policies")
|
||||||
async def list_policies(request: Request):
|
def list_policies(request: Request):
|
||||||
_owner_or_admin(request)
|
_owner_or_admin(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
||||||
@@ -74,7 +74,7 @@ async def upsert_policy(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/agent-approvals")
|
@router.get("/api/v2/agent-approvals")
|
||||||
async def list_approvals(request: Request):
|
def list_approvals(request: Request):
|
||||||
_owner_or_admin(request)
|
_owner_or_admin(request)
|
||||||
status = request.query_params.get("status", "pending")
|
status = request.query_params.get("status", "pending")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sources")
|
@router.get("/sources")
|
||||||
async def import_sources(request: Request):
|
def import_sources(request: Request):
|
||||||
"""List every available importer for the UI source picker."""
|
"""List every available importer for the UI source picker."""
|
||||||
return {"sources": list_sources()}
|
return {"sources": list_sources()}
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/relations/resolve")
|
@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."""
|
"""Convert text columns referencing another collection into relation props."""
|
||||||
ws_id, _ = _workspace(request)
|
ws_id, _ = _workspace(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/jobs")
|
@router.get("/jobs")
|
||||||
async def import_jobs(request: Request):
|
def import_jobs(request: Request):
|
||||||
return {"jobs": list_jobs()}
|
return {"jobs": list_jobs()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}")
|
@router.get("/jobs/{job_id}")
|
||||||
async def import_job(job_id: str):
|
def import_job(job_id: str):
|
||||||
job = get_job(job_id)
|
job = get_job(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(404, "Job not found")
|
raise HTTPException(404, "Job not found")
|
||||||
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}/report")
|
@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."""
|
"""Download a job's import report as JSON."""
|
||||||
job = get_job(job_id)
|
job = get_job(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
|
|||||||
+8
-161
@@ -157,7 +157,7 @@ BASE_SELECT = (
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/recents")
|
@router.get("/recents")
|
||||||
async def library_recents(
|
def library_recents(
|
||||||
request: Request,
|
request: Request,
|
||||||
source_type: str = Query(default="all"),
|
source_type: str = Query(default="all"),
|
||||||
tree: int = Query(default=0),
|
tree: int = Query(default=0),
|
||||||
@@ -191,7 +191,7 @@ async def library_recents(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/favorites")
|
@router.get("/favorites")
|
||||||
async def library_favorites(
|
def library_favorites(
|
||||||
request: Request,
|
request: Request,
|
||||||
source_type: str = Query(default="all"),
|
source_type: str = Query(default="all"),
|
||||||
tree: int = Query(default=0),
|
tree: int = Query(default=0),
|
||||||
@@ -221,7 +221,7 @@ async def library_favorites(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/shared")
|
@router.get("/shared")
|
||||||
async def library_shared(
|
def library_shared(
|
||||||
request: Request,
|
request: Request,
|
||||||
source_type: str = Query(default="all"),
|
source_type: str = Query(default="all"),
|
||||||
tree: int = Query(default=0),
|
tree: int = Query(default=0),
|
||||||
@@ -292,7 +292,7 @@ async def library_shared(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/published")
|
@router.get("/published")
|
||||||
async def library_published(
|
def library_published(
|
||||||
request: Request,
|
request: Request,
|
||||||
source_type: str = Query(default="all"),
|
source_type: str = Query(default="all"),
|
||||||
tree: int = Query(default=0),
|
tree: int = Query(default=0),
|
||||||
@@ -317,7 +317,7 @@ async def library_published(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/private")
|
@router.get("/private")
|
||||||
async def library_private(
|
def library_private(
|
||||||
request: Request,
|
request: Request,
|
||||||
source_type: str = Query(default="all"),
|
source_type: str = Query(default="all"),
|
||||||
tree: int = Query(default=0),
|
tree: int = Query(default=0),
|
||||||
@@ -341,76 +341,8 @@ async def library_private(
|
|||||||
return {"items": items}
|
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}")
|
@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)."""
|
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||||
uid = _get_user_id(request)
|
uid = _get_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -426,7 +358,7 @@ async def library_children(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/repository")
|
@router.get("/repository")
|
||||||
async def library_repository(
|
def library_repository(
|
||||||
request: Request,
|
request: Request,
|
||||||
gitea_owner: str = Query(default=""),
|
gitea_owner: str = Query(default=""),
|
||||||
gitea_repo: str = Query(default=""),
|
gitea_repo: str = Query(default=""),
|
||||||
@@ -449,93 +381,8 @@ async def library_repository(
|
|||||||
return {"items": items}
|
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")
|
@router.get("/workspace")
|
||||||
async def library_workspace(
|
def library_workspace(
|
||||||
request: Request,
|
request: Request,
|
||||||
source_type: str = Query(default="all"),
|
source_type: str = Query(default="all"),
|
||||||
tree: int = Query(default=0),
|
tree: int = Query(default=0),
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ async def create_link(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/calendar-links")
|
@router.get("/api/v2/calendar-links")
|
||||||
async def get_links(request: Request):
|
def get_links(request: Request):
|
||||||
user = _auth_user(request)
|
user = _auth_user(request)
|
||||||
return {"links": cal.list_links(user["id"])}
|
return {"links": cal.list_links(user["id"])}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/calendar-links/{link_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)
|
user = _auth_user(request, require_write=True)
|
||||||
if not cal.delete_link(user["id"], link_id):
|
if not cal.delete_link(user["id"], link_id):
|
||||||
raise HTTPException(404, "Link not found")
|
raise HTTPException(404, "Link not found")
|
||||||
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
|
|||||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/db/{collection_id}/calendar/freebusy")
|
@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)
|
_auth_user(request)
|
||||||
qp = request.query_params
|
qp = request.query_params
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
@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."""
|
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
user_login = user.get("login", "admin") if user else "admin"
|
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")
|
@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."""
|
"""API: return my tasks as JSON."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
user.get("login", "admin") if user else "admin"
|
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)
|
@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:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
|
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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."""
|
"""List the current user's notifications, newest first."""
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/unread-count")
|
@router.get("/unread-count")
|
||||||
async def unread_count(request: Request):
|
def unread_count(request: Request):
|
||||||
"""Unread count for the topbar badge."""
|
"""Unread count for the topbar badge."""
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/prefs")
|
@router.get("/prefs")
|
||||||
async def get_prefs(request: Request):
|
def get_prefs(request: Request):
|
||||||
"""Return the current user's notification email preferences."""
|
"""Return the current user's notification email preferences."""
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
from app.services import notifications as notif
|
from app.services import notifications as notif
|
||||||
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/timezone")
|
@router.get("/timezone")
|
||||||
async def get_timezone(request: Request):
|
def get_timezone(request: Request):
|
||||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/users/search")
|
@router.get("/users/search")
|
||||||
async def search_users(request: Request, q: str = ""):
|
def search_users(request: Request, q: str = ""):
|
||||||
"""User autocomplete for @mentions."""
|
"""User autocomplete for @mentions."""
|
||||||
_current_user(request)
|
_current_user(request)
|
||||||
q = (q or "").strip()
|
q = (q or "").strip()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/welcome", response_class=HTMLResponse)
|
@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
|
"""Onboarding wizard. Redirects logged-out users to login and users who
|
||||||
already have a workspace straight to the app."""
|
already have a workspace straight to the app."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
|||||||
+14
-14
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/permissions")
|
@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."""
|
"""List explicit page grants + the caller's effective role."""
|
||||||
pm = _pm(request)
|
pm = _pm(request)
|
||||||
if not pm.can_view_page(page_id):
|
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")
|
@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)."""
|
"""Effective role of the current user on a page (UI gating)."""
|
||||||
pm = _pm(request)
|
pm = _pm(request)
|
||||||
if not pm.can_view_page(page_id):
|
if not pm.can_view_page(page_id):
|
||||||
@@ -224,7 +224,7 @@ async def batch_page_permissions(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
@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)
|
pm = _pm(request)
|
||||||
if not pm.can_manage_page_permissions(page_id):
|
if not pm.can_manage_page_permissions(page_id):
|
||||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||||
@@ -243,7 +243,7 @@ async def set_page_permission_type(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/permissions")
|
@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)
|
pm = _pm(request)
|
||||||
if not pm.can_view_collection(collection_id):
|
if not pm.can_view_collection(collection_id):
|
||||||
raise HTTPException(404, "Collection not found")
|
raise HTTPException(404, "Collection not found")
|
||||||
@@ -276,7 +276,7 @@ async def grant_collection_permission(collection_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
@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)
|
pm = _pm(request)
|
||||||
if not pm.can_manage_collection_permissions(collection_id):
|
if not pm.can_manage_collection_permissions(collection_id):
|
||||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||||
@@ -294,7 +294,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/properties/visible")
|
@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."""
|
"""Split property ids into visible / hidden for the current user."""
|
||||||
pm = _pm(request)
|
pm = _pm(request)
|
||||||
if not pm.can_view_collection(collection_id):
|
if not pm.can_view_collection(collection_id):
|
||||||
@@ -316,7 +316,7 @@ async def visible_properties(collection_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
@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)
|
pm = _pm(request)
|
||||||
if not pm.can_view_collection(collection_id):
|
if not pm.can_view_collection(collection_id):
|
||||||
raise HTTPException(404, "Collection not found")
|
raise HTTPException(404, "Collection not found")
|
||||||
@@ -349,7 +349,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
@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):
|
perm_id: int, request: Request):
|
||||||
pm = _pm(request)
|
pm = _pm(request)
|
||||||
if not pm.can_manage_collection_permissions(collection_id):
|
if not pm.can_manage_collection_permissions(collection_id):
|
||||||
@@ -362,7 +362,7 @@ async def revoke_property_permission(collection_id: int, property_id: int,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/groups")
|
@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)
|
user = _require_user(request)
|
||||||
pm = PermissionManager(user["id"])
|
pm = PermissionManager(user["id"])
|
||||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||||
@@ -405,7 +405,7 @@ async def update_group(group_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/groups/{group_id}")
|
@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)
|
pm = _pm(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/groups/{group_id}/members")
|
@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)
|
user = _require_user(request)
|
||||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||||
|
|
||||||
@@ -451,7 +451,7 @@ async def add_group_member(group_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
@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)
|
pm = _pm(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@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."""
|
"""Workspace members (+ admins) for the grant pickers."""
|
||||||
_require_user(request)
|
_require_user(request)
|
||||||
q = (q or "").strip().lower()
|
q = (q or "").strip().lower()
|
||||||
@@ -503,7 +503,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/audit/permissions")
|
@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."""
|
"""Full permission change history — workspace owner/admin only."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_projects(request: Request):
|
def list_projects(request: Request):
|
||||||
"""List all synced projects (optionally filtered by type)."""
|
"""List all synced projects (optionally filtered by type)."""
|
||||||
proj_type = request.query_params.get("type") or None
|
proj_type = request.query_params.get("type") or None
|
||||||
return {"projects": projects_svc.list_projects(proj_type)}
|
return {"projects": projects_svc.list_projects(proj_type)}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/token")
|
@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
|
"""Generate a public API access token (A4 : session obligatoire — plus de
|
||||||
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
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)])
|
@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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
||||||
return {"collections": [dict(r) for r in rows]}
|
return {"collections": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
|
@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:
|
with get_conn() as conn:
|
||||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
if not coll:
|
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)])
|
@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:
|
with get_conn() as conn:
|
||||||
pages = conn.execute(
|
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",
|
"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)])
|
@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:
|
with get_conn() as conn:
|
||||||
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||||
if not p:
|
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)])
|
@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)."""
|
"""Public API: list tasks (requires valid token)."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
pages = conn.execute(
|
pages = conn.execute(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/realtime/stats")
|
@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.
|
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||||
|
|
||||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||||
|
|||||||
+7
-7
@@ -60,7 +60,7 @@ def _scim_user(row) -> dict:
|
|||||||
# ── SCIM resources ─────────────────────────────────────────────────────────
|
# ── SCIM resources ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/scim/v2/Users")
|
@router.get("/scim/v2/Users")
|
||||||
async def scim_list(request: Request):
|
def scim_list(request: Request):
|
||||||
_scim_guard(request)
|
_scim_guard(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
||||||
@@ -99,7 +99,7 @@ async def scim_create(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/scim/v2/Users/{user_id}")
|
@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)
|
_scim_guard(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
@@ -164,7 +164,7 @@ async def scim_patch(user_id: str, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/scim/v2/Users/{user_id}")
|
@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)
|
_scim_guard(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
@@ -200,7 +200,7 @@ async def create_scim_token(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/scim/tokens")
|
@router.get("/api/v2/scim/tokens")
|
||||||
async def list_scim_tokens(request: Request):
|
def list_scim_tokens(request: Request):
|
||||||
_admin_session(request)
|
_admin_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
||||||
@@ -209,7 +209,7 @@ async def list_scim_tokens(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/scim/tokens/{token_id}")
|
@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)
|
admin = _admin_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||||
@@ -221,7 +221,7 @@ async def revoke_scim_token(token_id: int, request: Request):
|
|||||||
# ── domain claims ──────────────────────────────────────────────────────────
|
# ── domain claims ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/v2/domain-claims")
|
@router.get("/api/v2/domain-claims")
|
||||||
async def list_domains(request: Request):
|
def list_domains(request: Request):
|
||||||
_admin_session(request)
|
_admin_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
||||||
@@ -288,7 +288,7 @@ async def verify_domain(domain_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/domain-claims/{domain_id}")
|
@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)
|
admin = _admin_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/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 = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
user_id = user.get("id") if user and user.get("id") else None
|
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")
|
@router.get("/api/v2/search/hybrid")
|
||||||
async def hybrid(request: Request):
|
def hybrid(request: Request):
|
||||||
user = _auth_user(request)
|
user = _auth_user(request)
|
||||||
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
||||||
if not q:
|
if not q:
|
||||||
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/search/index-status")
|
@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)."""
|
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
||||||
user = _auth_user(request)
|
user = _auth_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/tokens")
|
@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)."""
|
"""List the current user's API tokens (prefix only, no secrets)."""
|
||||||
uid = _current_user_id(request)
|
uid = _current_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -63,7 +63,7 @@ async def create_token(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/tokens/{token_id:int}")
|
@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)."""
|
"""Revoke an API token (soft delete)."""
|
||||||
uid = _current_user_id(request)
|
uid = _current_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sessions")
|
@router.get("/sessions")
|
||||||
async def list_sessions(request: Request):
|
def list_sessions(request: Request):
|
||||||
"""List the current user's active sessions with their devices."""
|
"""List the current user's active sessions with their devices."""
|
||||||
uid = _current_user_id(request)
|
uid = _current_user_id(request)
|
||||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||||
@@ -101,7 +101,7 @@ async def list_sessions(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{sid}/revoke")
|
@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."""
|
"""Revoke an active session. If it's the current one, the user is logged out."""
|
||||||
uid = _current_user_id(request)
|
uid = _current_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
@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."""
|
"""Remove a share invitation."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/shares")
|
@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."""
|
"""Get all shares for a page."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/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."""
|
"""Get the current user's sidebar customization config."""
|
||||||
user = _get_user(request)
|
user = _get_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+11
-11
@@ -315,7 +315,7 @@ async def create_site(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/sites")
|
@router.get("/api/v2/sites")
|
||||||
async def list_sites(request: Request):
|
def list_sites(request: Request):
|
||||||
user = _auth_user(request)
|
user = _auth_user(request)
|
||||||
limit, offset = parse_pagination(request)
|
limit, offset = parse_pagination(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/sites/{site_id}")
|
@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)
|
user = _auth_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||||
@@ -407,7 +407,7 @@ async def update_site(site_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/sites/{site_id}")
|
@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)
|
user = _auth_user(request, require_write=True)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||||
@@ -422,7 +422,7 @@ async def delete_site(site_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/sites/{site_id}/pages")
|
@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)
|
user = _auth_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||||
@@ -461,7 +461,7 @@ async def add_site_page(site_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
@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)
|
user = _auth_user(request, require_write=True)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||||
@@ -478,7 +478,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/sites/{site_id}/stats")
|
@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)
|
user = _auth_user(request)
|
||||||
days = max(1, min(int(days or 30), 365))
|
days = max(1, min(int(days or 30), 365))
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
@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:
|
with get_conn() as conn:
|
||||||
site = _resolve_site(conn, slug=slug,
|
site = _resolve_site(conn, slug=slug,
|
||||||
host=request.headers.get("host", ""))
|
host=request.headers.get("host", ""))
|
||||||
@@ -537,7 +537,7 @@ async def public_site_home(request: Request, slug: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
@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:
|
with get_conn() as conn:
|
||||||
site = _resolve_site(conn, slug=slug)
|
site = _resolve_site(conn, slug=slug)
|
||||||
if not site or _site_expired(site) or site.get("password_hash"):
|
if not site or _site_expired(site) or site.get("password_hash"):
|
||||||
@@ -556,7 +556,7 @@ async def site_sitemap(request: Request, slug: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
@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:
|
with get_conn() as conn:
|
||||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||||
if not site:
|
if not site:
|
||||||
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
|
|||||||
# ── Public Forms ───────────────────────────────────────────────────────────
|
# ── Public Forms ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/v2/collections/{collection_id}/form")
|
@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)
|
_auth_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
info = _form_config(conn, collection_id)
|
info = _form_config(conn, collection_id)
|
||||||
@@ -662,7 +662,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
@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"
|
embed = request.query_params.get("embed") == "1"
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||||
|
|||||||
+3
-3
@@ -106,7 +106,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/auth/saml/login")
|
@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."""
|
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||||
if not _rate_ok(request, "saml"):
|
if not _rate_ok(request, "saml"):
|
||||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||||
@@ -218,7 +218,7 @@ async def saml_callback(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/auth/saml/metadata")
|
@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…)."""
|
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||||
cfg = _sso_config_or_error()
|
cfg = _sso_config_or_error()
|
||||||
if not cfg or cfg["provider_type"] != "saml":
|
if not cfg or cfg["provider_type"] != "saml":
|
||||||
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/sso/providers")
|
@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)."""
|
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||||
cfg = _sso_config_or_error()
|
cfg = _sso_config_or_error()
|
||||||
if not cfg:
|
if not cfg:
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@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)):
|
authorization: str | None = Header(default=None)):
|
||||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||||
user = _user(request, authorization, required_scope="read")
|
user = _user(request, authorization, required_scope="read")
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ async def revoke_extension_device(device_id: int, request: Request):
|
|||||||
# ── HTML: /extensions download page ──
|
# ── HTML: /extensions download page ──
|
||||||
|
|
||||||
@router.get("/extensions", response_class=HTMLResponse)
|
@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.routers.dashboard import _sidebar_data
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/register/begin")
|
@router.post("/register/begin")
|
||||||
async def register_begin(request: Request):
|
def register_begin(request: Request):
|
||||||
if not _require_lib():
|
if not _require_lib():
|
||||||
raise HTTPException(501, "WebAuthn library not installed")
|
raise HTTPException(501, "WebAuthn library not installed")
|
||||||
from webauthn import generate_registration_options, options_to_json
|
from webauthn import generate_registration_options, options_to_json
|
||||||
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/keys")
|
@router.get("/keys")
|
||||||
async def list_keys(request: Request):
|
def list_keys(request: Request):
|
||||||
user = _session_user(request)
|
user = _session_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
||||||
@@ -202,7 +202,7 @@ async def list_keys(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/keys/{key_id}")
|
@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)
|
user = _session_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||||
|
|||||||
+17
-17
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
|
|||||||
# ── teamspaces ─────────────────────────────────────────────────────────────
|
# ── teamspaces ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/teamspaces")
|
@router.get("/api/v2/wiki/teamspaces")
|
||||||
async def list_teamspaces(request: Request):
|
def list_teamspaces(request: Request):
|
||||||
user = _user(request)
|
user = _user(request)
|
||||||
wid = request.query_params.get("workspace_id")
|
wid = request.query_params.get("workspace_id")
|
||||||
if wid:
|
if wid:
|
||||||
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
|
@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."""
|
"""Teamspace detail HTML page — sidebar entry point."""
|
||||||
user = _user(request)
|
user = _user(request)
|
||||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||||
@@ -204,7 +204,7 @@ async def create_teamspace(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
|
@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)
|
user = _user(request)
|
||||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||||
@@ -213,7 +213,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
|
@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)
|
user = _user(request)
|
||||||
_teamspace_or_404(teamspace_id, user["id"])
|
_teamspace_or_404(teamspace_id, user["id"])
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -251,7 +251,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
@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)
|
user = _user(request)
|
||||||
_teamspace_or_404(teamspace_id, user["id"])
|
_teamspace_or_404(teamspace_id, user["id"])
|
||||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||||
@@ -268,7 +268,7 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
|||||||
# ── verified pages ─────────────────────────────────────────────────────────
|
# ── verified pages ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/pages/{page_id}/verification")
|
@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)
|
_user(request)
|
||||||
_page_or_404(page_id)
|
_page_or_404(page_id)
|
||||||
return {"verification": wiki.verification(page_id)}
|
return {"verification": wiki.verification(page_id)}
|
||||||
@@ -292,7 +292,7 @@ async def verify_page(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
|
@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)
|
user = _user(request)
|
||||||
_page_or_404(page_id)
|
_page_or_404(page_id)
|
||||||
if not wiki.unverify_page(page_id):
|
if not wiki.unverify_page(page_id):
|
||||||
@@ -302,7 +302,7 @@ async def unverify_page(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/verified")
|
@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."""
|
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
|
||||||
user = _user(request)
|
user = _user(request)
|
||||||
wid = _workspace_id(request)
|
wid = _workspace_id(request)
|
||||||
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
|
|||||||
# ── follows ────────────────────────────────────────────────────────────────
|
# ── follows ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/api/v2/wiki/pages/{page_id}/follow")
|
@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)
|
user = _user(request)
|
||||||
_page_or_404(page_id)
|
_page_or_404(page_id)
|
||||||
now = wiki.toggle_follow(page_id, user["id"])
|
now = wiki.toggle_follow(page_id, user["id"])
|
||||||
@@ -339,7 +339,7 @@ async def follow_page(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/pages/{page_id}/followers")
|
@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)
|
_user(request)
|
||||||
_page_or_404(page_id)
|
_page_or_404(page_id)
|
||||||
ids = wiki.followers(page_id)
|
ids = wiki.followers(page_id)
|
||||||
@@ -372,7 +372,7 @@ async def react(comment_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
|
@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)
|
_user(request)
|
||||||
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
||||||
|
|
||||||
@@ -402,7 +402,7 @@ async def create_guest(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/pages/{page_id}/guests")
|
@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)
|
_user(request)
|
||||||
_page_or_404(page_id)
|
_page_or_404(page_id)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -413,7 +413,7 @@ async def list_guests(page_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/wiki/guests/{share_id}")
|
@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)
|
user = _user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
||||||
@@ -436,7 +436,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/g/{token}", response_class=HTMLResponse)
|
@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."""
|
"""Account-less page access (read-only or commenter). 404 if inactive."""
|
||||||
share = wiki.resolve_guest_share(token)
|
share = wiki.resolve_guest_share(token)
|
||||||
if not share:
|
if not share:
|
||||||
@@ -465,7 +465,7 @@ Editing is disabled.</div>
|
|||||||
# ── page views ─────────────────────────────────────────────────────────────
|
# ── page views ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/v2/wiki/pages/{page_id}/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)
|
user = _user(request)
|
||||||
page = _page_or_404(page_id)
|
page = _page_or_404(page_id)
|
||||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||||
@@ -476,7 +476,7 @@ async def page_views(page_id: int, request: Request):
|
|||||||
# ── wiki home ──────────────────────────────────────────────────────────────
|
# ── wiki home ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/v2/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."""
|
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
|
||||||
user = _user(request)
|
user = _user(request)
|
||||||
wid = _workspace_id(request)
|
wid = _workspace_id(request)
|
||||||
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/v2/wiki/verify-expiry-sweep")
|
@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)."""
|
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
|
||||||
user = _user(request)
|
user = _user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/workers")
|
@router.get("/api/v2/workers")
|
||||||
async def list_workers(request: Request):
|
def list_workers(request: Request):
|
||||||
_auth_user(request)
|
_auth_user(request)
|
||||||
limit, offset = parse_pagination(request)
|
limit, offset = parse_pagination(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/workers/{worker_id}")
|
@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)
|
user = _auth_user(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||||
@@ -146,7 +146,7 @@ async def update_worker(worker_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v2/workers/{worker_id}")
|
@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)
|
user = _auth_user(request, require_write=True)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||||
@@ -186,7 +186,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
@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)
|
_auth_user(request)
|
||||||
limit, _offset = parse_pagination(request, default_limit=20)
|
limit, _offset = parse_pagination(request, default_limit=20)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -199,7 +199,7 @@ async def worker_runs(worker_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
@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)
|
user = _auth_user(request, require_write=True)
|
||||||
out = worker_service.fork_worker(worker_id, user["id"])
|
out = worker_service.fork_worker(worker_id, user["id"])
|
||||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||||
@@ -207,7 +207,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v2/workers-usage")
|
@router.get("/api/v2/workers-usage")
|
||||||
async def workers_usage(request: Request):
|
def workers_usage(request: Request):
|
||||||
user = _auth_user(request)
|
user = _auth_user(request)
|
||||||
ws_raw = request.query_params.get("workspace_id")
|
ws_raw = request.query_params.get("workspace_id")
|
||||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||||
|
|||||||
+19
-19
@@ -79,7 +79,7 @@ async def create_workspace(request: Request):
|
|||||||
# ── Members ──
|
# ── Members ──
|
||||||
|
|
||||||
@router.get("/{ws_id}/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", "")):
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||||
raise HTTPException(401, "Authentication required")
|
raise HTTPException(401, "Authentication required")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -122,7 +122,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{ws_id}/members/{user_id}")
|
@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)
|
_require_ws_admin(request, ws_id)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||||
@@ -133,7 +133,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
|||||||
# ── Comments ──
|
# ── Comments ──
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
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",
|
"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",
|
||||||
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
|
|||||||
# ── Page History ──
|
# ── Page History ──
|
||||||
|
|
||||||
@router.get("/pages/{page_id}/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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||||
@@ -214,7 +214,7 @@ async def record_history(request: Request, page_id: int):
|
|||||||
# ── Favorites ──
|
# ── Favorites ──
|
||||||
|
|
||||||
@router.get("/favorites")
|
@router.get("/favorites")
|
||||||
async def list_favorites(request: Request):
|
def list_favorites(request: Request):
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
uid = user.get("id", 1)
|
uid = user.get("id", 1)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/favorites/{fav_id}")
|
@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:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
|
|||||||
# ── Templates ──
|
# ── Templates ──
|
||||||
|
|
||||||
@router.get("/templates/database")
|
@router.get("/templates/database")
|
||||||
async def list_db_templates(request: Request):
|
def list_db_templates(request: Request):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||||
return {"templates": [dict(r) for r in rows]}
|
return {"templates": [dict(r) for r in rows]}
|
||||||
@@ -296,7 +296,7 @@ async def apply_db_template(request: Request, tid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/templates/page")
|
@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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||||
@@ -369,7 +369,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}/templates/page/{tid}")
|
@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."""
|
"""Delete a page template."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
tmpl = conn.execute(
|
tmpl = conn.execute(
|
||||||
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
|
|||||||
# ── v4.2.0: Dashboards ──
|
# ── v4.2.0: Dashboards ──
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/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."""
|
"""List all dashboards for a collection."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -436,7 +436,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}/dashboards/{did}")
|
@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."""
|
"""Delete a dashboard."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
dash = conn.execute(
|
dash = conn.execute(
|
||||||
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
|
|||||||
# ── v4.5.0: Sprints ──
|
# ── v4.5.0: Sprints ──
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/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."""
|
"""List all sprints for a collection."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -528,7 +528,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
@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."""
|
"""Delete a sprint."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
sprint = conn.execute(
|
sprint = conn.execute(
|
||||||
@@ -572,7 +572,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
@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."""
|
"""Remove a page from a sprint."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
@@ -586,7 +586,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
@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."""
|
"""Calculate burndown data for a sprint."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
sprint = conn.execute(
|
sprint = conn.execute(
|
||||||
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/collections/{collection_id}/export/csv")
|
@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:
|
with get_conn() as conn:
|
||||||
pages = conn.execute(
|
pages = conn.execute(
|
||||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||||
@@ -690,7 +690,7 @@ async def export_csv(request: Request, collection_id: int):
|
|||||||
# ── Webhooks Outbound Management ──
|
# ── Webhooks Outbound Management ──
|
||||||
|
|
||||||
@router.get("/webhooks")
|
@router.get("/webhooks")
|
||||||
async def list_webhooks(request: Request):
|
def list_webhooks(request: Request):
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||||
@@ -723,7 +723,7 @@ async def create_webhook(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/webhooks/{wh_id}")
|
@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)
|
_require_admin(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||||
@@ -734,7 +734,7 @@ async def delete_webhook(request: Request, wh_id: int):
|
|||||||
# ── Public Sharing ──
|
# ── Public Sharing ──
|
||||||
|
|
||||||
@router.get("/public/{collection_id}")
|
@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.
|
"""Simple public read-only view — no auth required.
|
||||||
|
|
||||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||||
|
|||||||
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
|||||||
return {"status": "error", "detail": str(exc)}
|
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):
|
async def fire_event(event: str, payload: dict):
|
||||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||||
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||||
############################################################################}
|
############################################################################}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
/* ═════════════════════════════════════════════════════════════════════
|
/* ═════════════════════════════════════════════════════════════════════
|
||||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
.db-list-title{flex:1;min-width:120px}
|
.db-list-title{flex:1;min-width:120px}
|
||||||
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
||||||
</style>
|
</style>
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
<script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||||
|
|
||||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||||
@mouseleave="dragCloseTimer()">
|
@mouseleave="dragCloseTimer()">
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
document.addEventListener('alpine:init', function () {
|
document.addEventListener('alpine:init', function () {
|
||||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
############################################################################}
|
############################################################################}
|
||||||
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||||
(function () {
|
(function () {
|
||||||
var FD_ICONS = {
|
var FD_ICONS = {
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function () {
|
(function () {
|
||||||
if (window.__fdIconPickerRegistered) return;
|
if (window.__fdIconPickerRegistered) return;
|
||||||
window.__fdIconPickerRegistered = true;
|
window.__fdIconPickerRegistered = true;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
document.addEventListener('alpine:init', function () {
|
document.addEventListener('alpine:init', function () {
|
||||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||||
</style>
|
</style>
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||||
window.__fdRT = (function () {
|
window.__fdRT = (function () {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script type="application/json" id="page-data">
|
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
|
||||||
{{ page_data | tojson }}
|
{{ page_data | tojson }}
|
||||||
</script>
|
</script>
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
if (!window.__fdEditorScriptsLoaded) {
|
if (!window.__fdEditorScriptsLoaded) {
|
||||||
window.__fdEditorScriptsLoaded = true;
|
window.__fdEditorScriptsLoaded = true;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function accountsData() {
|
function accountsData() {
|
||||||
return {
|
return {
|
||||||
profile: { full_name: '', email: '' },
|
profile: { full_name: '', email: '' },
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
|
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function(){
|
(function(){
|
||||||
var defaultAPI = {
|
var defaultAPI = {
|
||||||
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
|
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function(){
|
(function(){
|
||||||
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
|
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
|
||||||
var FD_ADMIN_CMDS = [
|
var FD_ADMIN_CMDS = [
|
||||||
|
|||||||
@@ -113,6 +113,9 @@
|
|||||||
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
|
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
|
||||||
body.embed-mode .page-cover-area { padding-top: 0 !important; }
|
body.embed-mode .page-cover-area { padding-top: 0 !important; }
|
||||||
</style>
|
</style>
|
||||||
|
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||||
|
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||||
|
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||||
@@ -790,7 +793,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
// Inject CSRF token into HTMX headers
|
// Inject CSRF token into HTMX headers
|
||||||
(function() {
|
(function() {
|
||||||
const getCsrf = () => {
|
const getCsrf = () => {
|
||||||
@@ -2207,7 +2210,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function(){
|
(function(){
|
||||||
var PALETTE_ACTIONS = [
|
var PALETTE_ACTIONS = [
|
||||||
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
|
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
|
||||||
@@ -2378,7 +2381,7 @@
|
|||||||
|
|
||||||
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
|
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
|
||||||
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function() {
|
(function() {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
window.addEventListener('load', function() {
|
window.addEventListener('load', function() {
|
||||||
@@ -2392,7 +2395,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
|
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!window.FlowOffline) return;
|
if (!window.FlowOffline) return;
|
||||||
window.FlowOffline.onChange(function(s) {
|
window.FlowOffline.onChange(function(s) {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
var owner = '{{ owner }}';
|
var owner = '{{ owner }}';
|
||||||
var repo = '{{ repo }}';
|
var repo = '{{ repo }}';
|
||||||
var initialView = '{{ initial_view }}';
|
var initialView = '{{ initial_view }}';
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce() }}">
|
||||||
function openCardDetail(id) {
|
function openCardDetail(id) {
|
||||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||||
target: '#card-modal-content', swap: 'innerHTML'
|
target: '#card-modal-content', swap: 'innerHTML'
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce() }}">
|
||||||
// ponytail: CSRF helper
|
// ponytail: CSRF helper
|
||||||
function getCsrf() {
|
function getCsrf() {
|
||||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce() }}">
|
||||||
function openCardDetail(id) {
|
function openCardDetail(id) {
|
||||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||||
target: '#card-modal-content', swap: 'innerHTML'
|
target: '#card-modal-content', swap: 'innerHTML'
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
Alpine.data('giteaWorkspace', () => {
|
Alpine.data('giteaWorkspace', () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function importWizard() {
|
function importWizard() {
|
||||||
return {
|
return {
|
||||||
sources: [],
|
sources: [],
|
||||||
|
|||||||
@@ -206,7 +206,7 @@
|
|||||||
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
|
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
(function() {
|
(function() {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
window.addEventListener('load', function() {
|
window.addEventListener('load', function() {
|
||||||
|
|||||||
@@ -456,7 +456,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function libraryPage() {
|
function libraryPage() {
|
||||||
return {
|
return {
|
||||||
tab: 'recents',
|
tab: 'recents',
|
||||||
|
|||||||
@@ -480,7 +480,7 @@
|
|||||||
.preview-leave-end{transform:translateX(100%);opacity:0;}
|
.preview-leave-end{transform:translateX(100%);opacity:0;}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||||
window._wsInitData = (function() {
|
window._wsInitData = (function() {
|
||||||
return {
|
return {
|
||||||
@@ -2512,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
|
|||||||
_wsInitData = window._wsInitData;
|
_wsInitData = window._wsInitData;
|
||||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||||||
</script>
|
</script>
|
||||||
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||||
|
|
||||||
<div class="ws-split"
|
<div class="ws-split"
|
||||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
{% include "_database_table.html" %}
|
{% include "_database_table.html" %}
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
// Initialize database table from server-rendered data
|
// Initialize database table from server-rendered data
|
||||||
window.__DB_PAGE_ID = {{ page.id }};
|
window.__DB_PAGE_ID = {{ page.id }};
|
||||||
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
|
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block topbar %}{% endblock %}
|
{% block topbar %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_page_editor_content.html' %}
|
{% include '_page_editor_content.html' %}
|
||||||
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% include '_page_editor_scripts.html' %}
|
{% include '_page_editor_scripts.html' %}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
if (window.katex) {
|
if (window.katex) {
|
||||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||||
|
|||||||
@@ -1191,7 +1191,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function settingsInit() {
|
function settingsInit() {
|
||||||
return {
|
return {
|
||||||
activeSection: 'account',
|
activeSection: 'account',
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce() }}">
|
||||||
function tableView() {
|
function tableView() {
|
||||||
return {
|
return {
|
||||||
sortField: '',
|
sortField: '',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
|
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce() }}">
|
||||||
function teamLoad() {
|
function teamLoad() {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function trashData() {
|
function trashData() {
|
||||||
return {
|
return {
|
||||||
search: '',
|
search: '',
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function onboarding() {
|
function onboarding() {
|
||||||
return {
|
return {
|
||||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function workspacePage() {
|
function workspacePage() {
|
||||||
return {
|
return {
|
||||||
builtinProjects: [],
|
builtinProjects: [],
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
function workspacesPage() {
|
function workspacesPage() {
|
||||||
return {
|
return {
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
|
||||||
|
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
|
||||||
|
# dans ce cas, donc rien n'est bloqué).
|
||||||
|
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
|
||||||
|
|
||||||
ENV = Environment(
|
ENV = Environment(
|
||||||
loader=FileSystemLoader("app/templates"),
|
loader=FileSystemLoader("app/templates"),
|
||||||
autoescape=select_autoescape(["html"]),
|
autoescape=select_autoescape(["html"]),
|
||||||
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
|
|||||||
ASSET_VERSION = "dev"
|
ASSET_VERSION = "dev"
|
||||||
|
|
||||||
ENV.globals["asset_version"] = ASSET_VERSION
|
ENV.globals["asset_version"] = ASSET_VERSION
|
||||||
|
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||||
|
|||||||
+518
-84
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.5.0"
|
"version": "7.17.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
@@ -10765,47 +10765,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/library/local-workspace-children/{item_id}": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"library"
|
|
||||||
],
|
|
||||||
"summary": "Library Local Workspace Children",
|
|
||||||
"description": "Return children of a local workspace item for tree expansion.",
|
|
||||||
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "item_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Item Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/library/children/{page_id}": {
|
"/api/library/children/{page_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10899,48 +10858,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/library/local-workspace": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"library"
|
|
||||||
],
|
|
||||||
"summary": "Library Local Workspace",
|
|
||||||
"description": "Return local workspace items (files/folders) formatted for Library display.",
|
|
||||||
"operationId": "library_local_workspace_api_library_local_workspace_get",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "workspace_id",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 0,
|
|
||||||
"title": "Workspace Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/library/workspace": {
|
"/api/library/workspace": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -16725,6 +16642,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -16974,6 +16902,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17123,6 +17062,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17233,6 +17183,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17397,6 +17358,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17462,6 +17434,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17617,6 +17600,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17727,6 +17721,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -17837,6 +17842,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18003,6 +18019,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18178,6 +18205,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18288,6 +18326,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18454,6 +18503,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18564,6 +18624,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18674,6 +18745,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18849,6 +18931,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -18905,6 +18998,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19015,6 +19119,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19062,6 +19177,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19109,6 +19235,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19219,6 +19356,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19275,6 +19423,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19385,6 +19544,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19495,6 +19665,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19551,6 +19732,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19715,6 +19907,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19771,6 +19974,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -19881,6 +20095,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20125,6 +20350,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20217,6 +20453,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20365,6 +20612,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20421,6 +20679,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20531,6 +20800,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20753,6 +21033,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20809,6 +21100,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -20919,6 +21221,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21085,6 +21398,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21195,6 +21519,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21251,6 +21586,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21361,6 +21707,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21592,6 +21949,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21648,6 +22016,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -21861,6 +22240,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -22367,6 +22757,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -22618,6 +23019,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -22674,6 +23086,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -22961,6 +23384,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {},
|
||||||
|
"title": "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
|
|||||||
@@ -163,6 +163,113 @@ def test_gitea_cache_evicts_expired():
|
|||||||
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
|
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_transaction_rolls_back():
|
||||||
|
"""A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni
|
||||||
|
ligne dans schema_version → la reprise rejoue proprement."""
|
||||||
|
import sqlite3 as _sqlite3
|
||||||
|
|
||||||
|
import pytest as _pytest
|
||||||
|
|
||||||
|
from app.migrations import _apply_one, _ensure_table
|
||||||
|
|
||||||
|
conn = _sqlite3.connect(":memory:")
|
||||||
|
_ensure_table(conn)
|
||||||
|
|
||||||
|
def boom(c):
|
||||||
|
c.execute("CREATE TABLE partial_x (id INTEGER)")
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
with _pytest.raises(RuntimeError, match="boom"):
|
||||||
|
_apply_one(conn, 9999, "boom", boom)
|
||||||
|
assert (
|
||||||
|
conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone()
|
||||||
|
is None
|
||||||
|
), "DDL partiel non annulé"
|
||||||
|
assert (
|
||||||
|
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0]
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
# chemin nominal : DDL + marque de version dans la même transaction
|
||||||
|
_apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)"))
|
||||||
|
assert (
|
||||||
|
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns_helper_validates_table_name():
|
||||||
|
"""A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant."""
|
||||||
|
import sqlite3 as _sqlite3
|
||||||
|
|
||||||
|
from app.migrations import columns
|
||||||
|
|
||||||
|
conn = _sqlite3.connect(":memory:")
|
||||||
|
conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)")
|
||||||
|
assert columns(conn, "t1") == {"id", "nom"}
|
||||||
|
try:
|
||||||
|
columns(conn, "t1; DROP TABLE users")
|
||||||
|
raise AssertionError("identifiant non validé")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_nonce(csp: str, html: str) -> str:
|
||||||
|
"""Header CSP : script-src sans unsafe-inline + TOUS les scripts inline noncés."""
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
m = _re.search(r"script-src ([^;]*);", csp)
|
||||||
|
assert m, csp
|
||||||
|
script_src = m.group(1)
|
||||||
|
nm = _re.search(r"'nonce-([^']+)'", script_src)
|
||||||
|
assert nm, script_src
|
||||||
|
nonce = nm.group(1)
|
||||||
|
assert "'unsafe-inline'" not in script_src, script_src
|
||||||
|
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
|
||||||
|
assert "script-src-attr 'unsafe-inline'" in csp
|
||||||
|
tags = [
|
||||||
|
mm.group(0)
|
||||||
|
for mm in _re.finditer(r"<script[^>]*>", html)
|
||||||
|
if "src=" not in mm.group(0)
|
||||||
|
]
|
||||||
|
assert tags, "aucun script inline"
|
||||||
|
missing = [t for t in tags if f'nonce="{nonce}"' not in t]
|
||||||
|
assert missing == [], missing[:3]
|
||||||
|
return nonce
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_nonce_per_request(client):
|
||||||
|
"""A20 : nonce par requête — page base.html (avec meta htmx-config) et page
|
||||||
|
hors template (LOCAL_LOGIN_HTML, constante de module → nonce au rendu)."""
|
||||||
|
# 1) une page qui étend base.html (la meta htmx-config y est)
|
||||||
|
base = None
|
||||||
|
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
|
||||||
|
cand = client.get(url)
|
||||||
|
if cand.status_code == 200 and "htmx-config" in cand.text:
|
||||||
|
base = cand
|
||||||
|
break
|
||||||
|
assert base is not None, "aucune page base.html atteignable"
|
||||||
|
nonce = _assert_nonce(base.headers.get("content-security-policy", ""), base.text)
|
||||||
|
assert f'"inlineScriptNonce": "{nonce}"' in base.text
|
||||||
|
# deux requêtes = deux nonces différents
|
||||||
|
other = client.get("/dashboard")
|
||||||
|
if other.status_code == 200 and "htmx-config" in other.text:
|
||||||
|
other_nonce = _re_search_nonce(other.headers.get("content-security-policy", ""))
|
||||||
|
assert other_nonce != nonce
|
||||||
|
|
||||||
|
# 2) la page de login hors template (script injecté par _with_nonce)
|
||||||
|
r = client.get("/auth/login?provider=local")
|
||||||
|
assert r.status_code == 200, r.status_code
|
||||||
|
_assert_nonce(r.headers.get("content-security-policy", ""), r.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _re_search_nonce(csp: str) -> str:
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
|
||||||
|
|
||||||
|
|
||||||
def test_no_duplicate_routes():
|
def test_no_duplicate_routes():
|
||||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|||||||
@@ -0,0 +1,717 @@
|
|||||||
|
"""A32 — smoke tests : un par route des 4 routers à 0 test.
|
||||||
|
|
||||||
|
`webhooks.py` (3), `notes.py` (2), `sidebar_config.py` (2), `github_routes.py` (2).
|
||||||
|
Aucun accès réseau réel : le secret webhook est piloté par monkeypatch et
|
||||||
|
`gitea.list_webhooks` est stubbé.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
OWNER, REPO = "smoke-a32", "repo-x"
|
||||||
|
|
||||||
|
|
||||||
|
# ── webhooks.py (0/3) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_webhook_receive_ok_without_secret(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
|
||||||
|
r = client.post("/api/webhook", json={"zen": "smoke"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_receive_rejects_bad_signature(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "gitea_webhook_secret", "s3cret")
|
||||||
|
r = client.post("/api/webhook", json={}) # pas d'en-tête X-Gitea-Signature
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "Invalid signature"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_register_fails_fast_without_secret(client, monkeypatch):
|
||||||
|
# secret vide → 400 AVANT tout appel réseau (gitea.list_webhooks)
|
||||||
|
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
|
||||||
|
r = client.post(f"/api/webhook/register/{OWNER}/{REPO}")
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "not configured" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_status_stubbed(client, monkeypatch):
|
||||||
|
from app.services import gitea_client
|
||||||
|
|
||||||
|
async def _no_hooks(*_a, **_k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_client.gitea, "list_webhooks", _no_hooks)
|
||||||
|
r = client.get(f"/api/webhook/status/{OWNER}/{REPO}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"registered": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── notes.py (0/2) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_notes_get_renders_html(client):
|
||||||
|
r = client.get(f"/notes/{OWNER}/{REPO}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "text/html" in r.headers["content-type"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_save_roundtrip(client):
|
||||||
|
payload = "note de smoke A32"
|
||||||
|
html_payload = "<b>&co</b>"
|
||||||
|
r = client.post(
|
||||||
|
f"/notes/{OWNER}/{REPO}", data={"content": f"{payload} {html_payload}"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert payload in r.text # contenu rendu
|
||||||
|
assert "<b>&co</b>" in r.text # échappé (A10 — pas d'HTML cru)
|
||||||
|
again = client.get(f"/notes/{OWNER}/{REPO}")
|
||||||
|
assert payload in again.text # persistée en base (upsert)
|
||||||
|
|
||||||
|
|
||||||
|
# ── sidebar_config.py (0/2) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sidebar_config_get(client):
|
||||||
|
r = client.get("/api/sidebar/config")
|
||||||
|
assert r.status_code == 200
|
||||||
|
cfg = r.json()["config"]
|
||||||
|
assert isinstance(cfg, dict) and "meetings" in cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_config_put_persists_and_validates(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/sidebar/config",
|
||||||
|
json={"config": {"meetings": {"visible": False, "order": 2, "show_count": 5}}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
back = client.get("/api/sidebar/config").json()["config"]
|
||||||
|
assert back["meetings"]["visible"] is False # relu depuis users.sidebar_config
|
||||||
|
|
||||||
|
bad = client.put("/api/sidebar/config", json={})
|
||||||
|
assert bad.status_code == 400 # config dict requis
|
||||||
|
|
||||||
|
# remise en état pour les autres tests
|
||||||
|
restore = client.put(
|
||||||
|
"/api/sidebar/config",
|
||||||
|
json={"config": {"meetings": {"visible": True, "order": 2, "show_count": 5}}},
|
||||||
|
)
|
||||||
|
assert restore.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── github_routes.py (0/2) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_github_status_unlinked(client):
|
||||||
|
r = client.get("/api/github/status")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"linked": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_github_disconnect_ok(client):
|
||||||
|
r = client.delete("/api/github/disconnect")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── library.py (1/10 → 10/10) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_page(title: str, **cols) -> int:
|
||||||
|
"""Insert minimale (title/content/format + colonnes surnuméraires), id renvoyé."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
base = {"workspace": "", "title": title, "content": "", "content_format": "markdown"}
|
||||||
|
base.update(cols) # surcharge (content=, parent_id=, parent_section=…)
|
||||||
|
columns = list(base)
|
||||||
|
placeholders = ", ".join("?" for _ in columns)
|
||||||
|
values = list(base.values())
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values
|
||||||
|
)
|
||||||
|
pid = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_all_lists_return_items(client):
|
||||||
|
"""Les 5 listes de la bibliothèque répondent {"items": [...]}."""
|
||||||
|
for path in ("/recents", "/favorites", "/published", "/private", "/workspace"):
|
||||||
|
r = client.get(f"/api/library{path}")
|
||||||
|
assert r.status_code == 200, (path, r.status_code)
|
||||||
|
assert isinstance(r.json()["items"], list), path
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_private_lists_seeded_page(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
pid = _seed_page("A32 private page", parent_section="Private")
|
||||||
|
try:
|
||||||
|
items = client.get("/api/library/private").json()["items"]
|
||||||
|
assert "A32 private page" in [i["title"] for i in items]
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_children_lists_child(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
parent = _seed_page("A32 parent")
|
||||||
|
child = _seed_page("A32 child", parent_id=parent)
|
||||||
|
try:
|
||||||
|
items = client.get(f"/api/library/children/{parent}").json()["items"]
|
||||||
|
assert [i["title"] for i in items] == ["A32 child"]
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (parent, child))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_local_workspace_routes_removed(client):
|
||||||
|
"""A32 → A30 : ces 2 routes lisaient `local_workspace_items`, une table
|
||||||
|
inexistante nulle part dans le codebase (500 systématique côté children,
|
||||||
|
500 d?s qu'un workspace existe côté local-workspace) et AUCUNE référence
|
||||||
|
front → supprimées avec `_format_size` devenu mort."""
|
||||||
|
assert client.get("/api/library/local-workspace-children/1").status_code == 404
|
||||||
|
assert client.get("/api/library/local-workspace").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_repository_empty_and_keyed(client):
|
||||||
|
empty = client.get("/api/library/repository")
|
||||||
|
assert empty.status_code == 200 and empty.json() == {"items": []}
|
||||||
|
keyed = client.get("/api/library/repository?gitea_owner=acme&gitea_repo=flowdeck")
|
||||||
|
assert keyed.status_code == 200
|
||||||
|
assert isinstance(keyed.json()["items"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_lists_still_available_after_removal(client):
|
||||||
|
"""Les listes restantes (celles qu'utilise le front) répondent toujours."""
|
||||||
|
r = client.get("/api/library/recents")
|
||||||
|
assert r.status_code == 200 and isinstance(r.json()["items"], list)
|
||||||
|
|
||||||
|
|
||||||
|
# ── api.py (3/23 → 11/23) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _seed_board(client) -> None:
|
||||||
|
"""Board via l'endpoint lui-même (pas de SQL brut)."""
|
||||||
|
r = client.post(
|
||||||
|
f"/api/board-config/{OWNER}/{REPO}",
|
||||||
|
params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_board_config_default_then_roundtrip(client):
|
||||||
|
# pas de board → défauts
|
||||||
|
r = client.get(f"/api/board-config/{OWNER}/other-repo")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"]
|
||||||
|
# création puis relecture
|
||||||
|
_seed_board(client)
|
||||||
|
back = client.get(f"/api/board-config/{OWNER}/{REPO}")
|
||||||
|
assert back.json()["columns"] == ["Todo", "Doing"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_col_mapping_requires_board_and_works(client):
|
||||||
|
# board inexistant → 404
|
||||||
|
r = client.post(
|
||||||
|
"/api/col-mapping",
|
||||||
|
params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
# avec board → ok (upsert) puis suppression
|
||||||
|
_seed_board(client)
|
||||||
|
r2 = client.post(
|
||||||
|
"/api/col-mapping",
|
||||||
|
params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"}
|
||||||
|
r3 = client.delete(
|
||||||
|
"/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"}
|
||||||
|
)
|
||||||
|
assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_card_metadata(client):
|
||||||
|
r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"})
|
||||||
|
assert r.status_code == 404 # board requis
|
||||||
|
_seed_board(client)
|
||||||
|
r2 = client.post(
|
||||||
|
f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"}
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_collaborators_stubbed(client, monkeypatch):
|
||||||
|
from app.services import gitea_client
|
||||||
|
|
||||||
|
async def _none(*_a, **_k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none)
|
||||||
|
r = client.get(f"/api/collaborators/{OWNER}/{REPO}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"collaborators": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_checklist_item_toggle_and_deletes(client):
|
||||||
|
"""PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
_seed_board(client)
|
||||||
|
with get_conn() as conn:
|
||||||
|
board_id = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(OWNER, REPO),
|
||||||
|
).fetchone()["id"]
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')",
|
||||||
|
(board_id,),
|
||||||
|
)
|
||||||
|
cl_id = cur.lastrowid
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')",
|
||||||
|
(cl_id,),
|
||||||
|
)
|
||||||
|
item_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
try:
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT checked, content FROM checklist_items WHERE id=?", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["checked"] == 1 and row["content"] == "fait !"
|
||||||
|
|
||||||
|
assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"}
|
||||||
|
assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
left = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,)
|
||||||
|
).fetchone()["n"]
|
||||||
|
assert left == 0
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,))
|
||||||
|
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _v2_headers(client, login: str) -> dict:
|
||||||
|
"""Compte local + token v1 en session (recette de test_public_api_v2)."""
|
||||||
|
r = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": f"{login}@test.dev", "password": "secret123", "name": login},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
tok = client.post("/api/v1/token").json()["token"]
|
||||||
|
return {"Authorization": f"Bearer {tok}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_frontend_error_capture_and_read(client):
|
||||||
|
# purge d'un éventuel résidu d'un autre test
|
||||||
|
client.get("/api/frontend-errors")
|
||||||
|
ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
|
||||||
|
assert ok.status_code == 200 and ok.json() == {"status": "ok"}
|
||||||
|
invalid = client.post(
|
||||||
|
"/api/frontend-error", content="pas du json",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert invalid.status_code == 200 and invalid.json()["status"] == "ignored"
|
||||||
|
# doublon consécutif dédupliqué (count=2), lecture puis purge
|
||||||
|
client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
|
||||||
|
out = client.get("/api/frontend-errors").json()
|
||||||
|
assert out["count"] == 1 and out["errors"][0]["count"] == 2 and out["cleared"] is True
|
||||||
|
assert client.get("/api/frontend-errors").json()["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── api_v2.py (+5 routes à 0 ref) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_evaluate_formula(client):
|
||||||
|
headers = _v2_headers(client, "smoke-formula")
|
||||||
|
r = client.post(
|
||||||
|
"/api/v2/properties/evaluate-formula",
|
||||||
|
json={"expression": "1 + 2"}, headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
d = r.json()
|
||||||
|
# le moteur décide du résultat (1 + 2 y est renvoyé tel quel aujourd'hui) :
|
||||||
|
# on valide le câble route (auth, parse, shape), pas le moteur lui-même.
|
||||||
|
assert d["expression"] == "1 + 2" and "result" in d
|
||||||
|
missing = client.post("/api/v2/properties/evaluate-formula", json={}, headers=headers)
|
||||||
|
assert missing.status_code == 400 and "expression" in missing.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_compute_rollup_validates_inputs(client):
|
||||||
|
headers = _v2_headers(client, "smoke-rollup")
|
||||||
|
r = client.post("/api/v2/properties/compute-rollup", json={}, headers=headers)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "collection_id required" in r.json()["detail"]
|
||||||
|
sans_auth = client.post("/api/v2/properties/compute-rollup", json={})
|
||||||
|
assert sans_auth.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_admin_audit_logs_gated_then_readable(client):
|
||||||
|
headers = _v2_headers(client, "smoke-audit")
|
||||||
|
# le portail dépend de l'utilisateur porteur (le TOUT premier utilisateur
|
||||||
|
# du worker est admin) → on calcule l'attendu depuis /users/me plutôt que
|
||||||
|
# de durcir un état qu'on ne contrôle pas.
|
||||||
|
me = client.get("/api/v2/users/me", headers=headers).json()
|
||||||
|
r = client.get("/api/v2/admin/audit-logs", headers=headers)
|
||||||
|
if me.get("is_admin"):
|
||||||
|
assert r.status_code == 200
|
||||||
|
else:
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert "Admin scope" in r.json()["detail"]
|
||||||
|
# token avec scope admin → 200 + liste paginée
|
||||||
|
admin_tok = client.post(
|
||||||
|
"/api/v2/tokens", json={"name": "adm", "scopes": "read,admin"}, headers=headers
|
||||||
|
).json()["token"]
|
||||||
|
r2 = client.get(
|
||||||
|
"/api/v2/admin/audit-logs", headers={"Authorization": f"Bearer {admin_tok}"}
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert isinstance(r2.json()["logs"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_webhooks_events_catalogue(client):
|
||||||
|
headers = _v2_headers(client, "smoke-events")
|
||||||
|
r = client.get("/api/v2/webhooks/events", headers=headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
d = r.json()
|
||||||
|
assert isinstance(d["events"], list) and len(d["events"]) > 0
|
||||||
|
assert "*" in d["wildcards"] and "page.*" in d["wildcards"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_webhook_verify_signature(client):
|
||||||
|
from app.services.webhook_outbound import sign_payload
|
||||||
|
|
||||||
|
headers = _v2_headers(client, "smoke-sig")
|
||||||
|
secret, payload = "topsecret", '{"event": "page.updated"}'
|
||||||
|
good = sign_payload(secret, payload.encode())
|
||||||
|
ok = client.post(
|
||||||
|
"/api/v2/webhooks/verify-signature",
|
||||||
|
json={"secret": secret, "payload": payload, "signature": good},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200 and ok.json()["valid"] is True
|
||||||
|
bad = client.post(
|
||||||
|
"/api/v2/webhooks/verify-signature",
|
||||||
|
json={"secret": secret, "payload": payload, "signature": "deadbeef"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert bad.json()["valid"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── dashboard.py (17/63 → 27/63) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_tags_crud(client):
|
||||||
|
"""POST/GET/PUT/DELETE /api/settings/tags — cycle complet vérifié."""
|
||||||
|
r = client.post("/api/settings/tags", json={"name": "SmokeTag", "color": "#111111"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
tag = r.json()["tag"]
|
||||||
|
assert tag["name"] == "smoketag" and tag["color"] == "#111111" # lowercasé
|
||||||
|
|
||||||
|
allr = client.get("/api/settings/tags/all").json()["tags"]
|
||||||
|
assert any(t["id"] == tag["id"] for t in allr)
|
||||||
|
|
||||||
|
assert client.put(
|
||||||
|
f"/api/settings/tags/{tag['id']}", json={"color": "#222222"}
|
||||||
|
).json() == {"status": "ok"}
|
||||||
|
allr2 = client.get("/api/settings/tags/all").json()["tags"]
|
||||||
|
mine = next(t for t in allr2 if t["id"] == tag["id"])
|
||||||
|
assert mine["color"] == "#222222"
|
||||||
|
|
||||||
|
assert client.delete(f"/api/settings/tags/{tag['id']}").json() == {"status": "ok"}
|
||||||
|
assert all(t["id"] != tag["id"] for t in client.get("/api/settings/tags/all").json()["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_page_content_rename_trash(client):
|
||||||
|
"""GET content → PUT rename → POST trash : la vie d'une page vérifiée en base."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
pid = _seed_page("Page A32", content="contenu a32")
|
||||||
|
try:
|
||||||
|
r = client.get(f"/api/pages/{pid}/content")
|
||||||
|
assert r.status_code == 200
|
||||||
|
d = r.json()
|
||||||
|
assert d["title"] == "Page A32" and d["content"] == "contenu a32"
|
||||||
|
|
||||||
|
rr = client.put(f"/api/pages/{pid}/rename", json={"title": "Renommée A32"})
|
||||||
|
assert rr.status_code == 200 and rr.json() == {"status": "ok", "title": "Renommée A32"}
|
||||||
|
empty = client.put(f"/api/pages/{pid}/rename", json={"title": " "})
|
||||||
|
assert empty.status_code == 400 # titre vide refusé
|
||||||
|
|
||||||
|
rt = client.post(f"/api/pages/{pid}/trash")
|
||||||
|
assert rt.status_code == 200 and rt.json() == {"status": "ok"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["title"] == "Renommée A32"
|
||||||
|
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_sidebar_workspace_tree(client):
|
||||||
|
r = client.get("/api/sidebar/workspace-tree")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "text/html" in r.headers["content-type"]
|
||||||
|
# pas de cookie workspace → fragment « No pages yet »
|
||||||
|
assert "No pages yet" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_avatar_color_persisted(client):
|
||||||
|
"""POST avatar-color → couleur relue SUR L'UTILISATEUR DE LA SESSION."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
uid = client.get("/api/users/me").json()["id"]
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()
|
||||||
|
prev_color, prev_url = row["avatar_color"], row["avatar_url"]
|
||||||
|
try:
|
||||||
|
r = client.post("/api/settings/avatar-color", json={"color": "#112233"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"status": "ok", "color": "#112233"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
row2 = conn.execute(
|
||||||
|
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()
|
||||||
|
assert row2["avatar_color"] == "#112233" and row2["avatar_url"] == ""
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET avatar_color=?, avatar_url=? WHERE id=?",
|
||||||
|
(prev_color, prev_url, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_workspace_members_list(client):
|
||||||
|
r = client.get("/api/workspace/1/members")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json()["members"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_account_pages_html(client):
|
||||||
|
"""Pages HTML /accounts et /accounts/settings (whitelist A2 vérifiée)."""
|
||||||
|
for path in ("/accounts", "/accounts/settings"):
|
||||||
|
r = client.get(path)
|
||||||
|
assert r.status_code == 200, path
|
||||||
|
assert "text/html" in r.headers["content-type"], path
|
||||||
|
# la page ne doit plus contenir de hash (colonnes whitelistées en A29)
|
||||||
|
assert "password_hash" not in client.get("/accounts").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_user_profile_update_persisted(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
uid = client.get("/api/users/me").json()["id"]
|
||||||
|
with get_conn() as conn:
|
||||||
|
prev = conn.execute("SELECT full_name FROM users WHERE id=?", (uid,)).fetchone()[
|
||||||
|
"full_name"
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
r = client.put("/api/user/profile", json={"full_name": "Smoke A32"})
|
||||||
|
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
now = conn.execute(
|
||||||
|
"SELECT full_name FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()["full_name"]
|
||||||
|
assert now == "Smoke A32"
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (prev, uid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_password_change_gated_by_current(client):
|
||||||
|
"""A3 : sans mdp actuel correct, la session ne permet PAS de changer le mdp."""
|
||||||
|
court = client.put("/api/user/password", json={"password": "abc"})
|
||||||
|
# quirk assumé : la longueur est validée AVANT auth et répond 200 + erreur
|
||||||
|
assert "at least 6 characters" in court.json().get("error", "")
|
||||||
|
faux = client.put(
|
||||||
|
"/api/user/password",
|
||||||
|
json={"password": "nouveaumdp1", "current_password": "pas-le-bon"},
|
||||||
|
)
|
||||||
|
assert faux.status_code == 403
|
||||||
|
assert "incorrect" in faux.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_user_token_and_forge_disconnect(client):
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
uid = client.get("/api/users/me").json()["id"]
|
||||||
|
try:
|
||||||
|
r = client.post("/api/user/token")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert _re.fullmatch(r"fd_[0-9a-f]{64}", r.json()["token"])
|
||||||
|
r2 = client.delete("/api/user/forge/gitea")
|
||||||
|
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (uid,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_settings_account_update(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
uid = client.get("/api/users/me").json()["id"]
|
||||||
|
with get_conn() as conn:
|
||||||
|
prev = conn.execute(
|
||||||
|
"SELECT full_name, email FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()
|
||||||
|
try:
|
||||||
|
r = client.put(
|
||||||
|
"/api/settings/account",
|
||||||
|
json={"full_name": "Compte A32", "email": "[email protected]"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT full_name, email FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["full_name"] == "Compte A32" and row["email"] == "[email protected]"
|
||||||
|
# mdp trop court → 400 (validateur, pas de changement)
|
||||||
|
court = client.put("/api/settings/account", json={"password": "abc"})
|
||||||
|
assert court.status_code == 400
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET full_name=?, email=? WHERE id=?",
|
||||||
|
(prev["full_name"], prev["email"], uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_files_traversal_denied_and_serve(client):
|
||||||
|
"""A16 : /api/files refuse le traversal et sert les vrais fichiers."""
|
||||||
|
import pathlib as _pathlib
|
||||||
|
|
||||||
|
from app.config import settings as _settings
|
||||||
|
|
||||||
|
# traversal encodé (%2e%2e%2f) → décodé par Starlette, bloqué par resolve()
|
||||||
|
r = client.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc/passwd")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["error"] == "Path traversal denied"
|
||||||
|
# inexistant → 404
|
||||||
|
assert client.get("/api/files/1/rien-du-tout.txt").status_code == 404
|
||||||
|
# vrai fichier écrit dans le data_dir de test → 200 + contenu
|
||||||
|
base = _pathlib.Path(_settings.data_dir) / "uploads" / "workspace_1"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
fp = base / "smoke-a32.txt"
|
||||||
|
fp.write_text("bonjour depuis le disque", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
ok = client.get("/api/files/1/smoke-a32.txt")
|
||||||
|
assert ok.status_code == 200
|
||||||
|
assert ok.content == b"bonjour depuis le disque"
|
||||||
|
finally:
|
||||||
|
fp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_download_and_file_content_guards(client):
|
||||||
|
"""A16 : download/file-content sur page fichier avec chemin qui s'échappe."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
md = _seed_page("Doc A32", content="du markdown")
|
||||||
|
fpage = _seed_page(
|
||||||
|
"Fichier A32", content="../outside.txt", content_format="file"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# page markdown → pas de fichier téléchargeable (404 propre, pas de 500)
|
||||||
|
dl = client.get(f"/api/pages/{md}/download")
|
||||||
|
assert dl.status_code == 404 and "downloadable" in dl.json()["error"]
|
||||||
|
# page « file » dont le chemin sort de la racine → ni 200 ni fuite
|
||||||
|
fc = client.get(f"/api/pages/{fpage}/file-content")
|
||||||
|
assert fc.status_code in (404, 415), fc.status_code
|
||||||
|
dl2 = client.get(f"/api/pages/{fpage}/download")
|
||||||
|
assert dl2.status_code == 404
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (md, fpage))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_page_content_and_avatar_redirect(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
pid = _seed_page("Preview A32", content="aperçu a32", content_format="markdown")
|
||||||
|
uid = client.get("/api/users/me").json()["id"]
|
||||||
|
with get_conn() as conn:
|
||||||
|
prev = conn.execute(
|
||||||
|
"SELECT avatar_url FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()["avatar_url"]
|
||||||
|
try:
|
||||||
|
r = client.get(f"/api/local-workspace/page-content/{pid}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"content": "aperçu a32", "format": "markdown"}
|
||||||
|
assert client.get("/api/local-workspace/page-content/999999").status_code == 404
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET avatar_url=? WHERE id=?",
|
||||||
|
("https://exemple.dev/a.png", uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
# follow_redirects=False : surtout pas de requête RÉELLE vers l'URL
|
||||||
|
# externe pointée par l'avatar (règle « 0 réseau » de l'audit)
|
||||||
|
red = client.get(f"/api/avatar/{uid}", follow_redirects=False)
|
||||||
|
assert red.status_code == 302
|
||||||
|
assert red.headers["location"] == "https://exemple.dev/a.png"
|
||||||
|
assert client.get("/api/avatar/999999").status_code == 404
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM pages WHERE id=?", (pid,)
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE users SET avatar_url=? WHERE id=?", (prev, uid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_collection_table_data_and_row_create(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
cid = client.post("/db/api", json={"name": "Coll A32"}).json()["id"]
|
||||||
|
try:
|
||||||
|
r = client.get(f"/api/collections/{cid}/table-data")
|
||||||
|
assert r.status_code == 200
|
||||||
|
d = r.json()
|
||||||
|
assert d["collection"]["name"] == "Coll A32"
|
||||||
|
assert d["pages"] == [] and isinstance(d["properties"], list)
|
||||||
|
assert client.get("/api/collections/999999/table-data").status_code == 404
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
f"/api/collections/{cid}/pages", json={"title": "Ligne A32"}
|
||||||
|
)
|
||||||
|
assert created.status_code in (200, 201), created.text
|
||||||
|
back = client.get(f"/api/collections/{cid}/table-data").json()["pages"]
|
||||||
|
assert any(p.get("title") == "Ligne A32" for p in back)
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM collection_pages WHERE collection_id=?", (cid,))
|
||||||
|
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_workspace_select_and_breadcrumb(client):
|
||||||
|
# select : cookie positionné + shape
|
||||||
|
r = client.post("/api/workspaces/1/select")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"status": "ok", "workspace_id": 1}
|
||||||
|
assert "flowdeck_workspace" in r.headers.get("set-cookie", "")
|
||||||
|
# breadcrumb d'un dossier inexistant → liste (shape)
|
||||||
|
b = client.get("/api/local-workspace/breadcrumb", params={"folder": 999999})
|
||||||
|
assert b.status_code == 200
|
||||||
|
assert isinstance(b.json()["breadcrumb"], list)
|
||||||
Reference in New Issue
Block a user