Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587ec8d61b | ||
|
|
7a38ddd0f6 | ||
|
|
113374e499 | ||
|
|
0cb476e336 | ||
|
|
2339fa2586 | ||
|
|
8b48dbdd4b | ||
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 | ||
|
|
df9a269d76 | ||
|
|
da7326ffde | ||
|
|
3a1276596c | ||
|
|
07904f05e5 | ||
|
|
224bda74d5 | ||
|
|
c718fe06de | ||
|
|
f706424f90 | ||
|
|
7be96f0618 | ||
|
|
937ecfc2e0 | ||
|
|
998b5c630c | ||
|
|
cb47f5c7f4 | ||
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e | ||
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf |
+2
-2
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
|
|||||||
DEFAULT_LANG=fr
|
DEFAULT_LANG=fr
|
||||||
|
|
||||||
# ── Database ──
|
# ── Database ──
|
||||||
# SQLite (default): sqlite:////data/flowdeck.db
|
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
|
||||||
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
|
# schéma retombe silencieusement sur /data/flowdeck.db).
|
||||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||||
|
|
||||||
# ── Sync ──
|
# ── Sync ──
|
||||||
|
|||||||
+514
@@ -1,5 +1,519 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.21.0 (2026-10-01) — Audit : A27 phase 1 (4 243 L de JS extraites)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A27 (phase 1)** — les 7 templates dont le JS **n'est pas interpolé Jinja**
|
||||||
|
sont extraits vers `static/js/*.js` : agent_panel_1/_2 (dont un bloc de
|
||||||
|
**1 788 lignes livré sur chaque page**), library (1 039), gitea_workspace
|
||||||
|
(626), _icon_picker_1/_2, _ctx_menu, import, workspaces →
|
||||||
|
**4 243 lignes, -30 % de JS inline** (13 904 → 9 661)
|
||||||
|
- Extraction **un fichier par bloc** : ordre et timing d'exécution identiques
|
||||||
|
(pas de defer/async, attributs d'origine conservés dont `data-cfasync`),
|
||||||
|
cache-busting `?v={{ asset_version }}` (source unique A40), CSP : les
|
||||||
|
scripts externes relèvent de `'self'` (pas de nonce requis)
|
||||||
|
|
||||||
|
### Lint
|
||||||
|
|
||||||
|
- ESLint **installé globalement** (`npm i -g eslint`) — `eslint.config.mjs`
|
||||||
|
existait déjà en flat config « sans dépendances » mais **aucun binaire**
|
||||||
|
n'était installé (d'où « 0 linté »)
|
||||||
|
- `eslint static/js` : **0 erreur, 120 warnings** sur 8 fichiers
|
||||||
|
(no-unused-vars 69, no-empty 36, no-undef 15) → baseline à nettoyer
|
||||||
|
- `node --check` vert sur les 9 fichiers extraits ; `eslint.config.mjs` couvre
|
||||||
|
déjà `static/js/**/*.js` donc les extraits sont lintés d'office
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Reste A27 : les blocs interpolés Jinja (page_editor 2 517, local_workspace
|
||||||
|
2 031, base 1 523, database_table 1 323, settings 1 093… ≈ 9 661 L) →
|
||||||
|
extraction en 2 temps (config JSON injectée + script statique)
|
||||||
|
- Suite complète : **1089/1089**
|
||||||
|
|
||||||
|
## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
|
||||||
|
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
|
||||||
|
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
|
||||||
|
code) → `{% from '_icons.html' import fd_icon %}` ajouté
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
|
||||||
|
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
|
||||||
|
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
|
||||||
|
aurait masqué la mise à jour)
|
||||||
|
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
|
||||||
|
recalculée **sans perdre la carte**
|
||||||
|
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
|
||||||
|
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
|
||||||
|
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
|
||||||
|
base**, 404 sans board
|
||||||
|
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
|
||||||
|
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
|
||||||
|
sidebar_config, github_routes, library, api, dashboard, api_v2)
|
||||||
|
|
||||||
|
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
|
||||||
|
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
|
||||||
|
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
|
||||||
|
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
|
||||||
|
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
|
||||||
|
· `upload-folder` : **validations seules** (structure absente → 400,
|
||||||
|
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
|
||||||
|
· `convert-to-database` : collection + propriété `title` + vue `table` +
|
||||||
|
page en `content_format='collection'` **vérifiés en base**, 404 page
|
||||||
|
inconnue, cleanup **dans l'ordre FK** (page avant collection)
|
||||||
|
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
|
||||||
|
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
|
||||||
|
paths en f-string dans les tests, rapprochés manuellement)
|
||||||
|
- Suite complète : **1086/1086**
|
||||||
|
|
||||||
|
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
|
||||||
|
`test_smoke_uncovered.py` : 46 tests :
|
||||||
|
· `/gitea-workspace` : page HTML (200 ou redirection propre)
|
||||||
|
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
|
||||||
|
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
|
||||||
|
nom, nettoyage
|
||||||
|
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
|
||||||
|
base** → move → soft-delete (`deleted_at` **relu**) → restore
|
||||||
|
(`deleted_at IS NULL` **relu**), nettoyage
|
||||||
|
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
|
||||||
|
liste workspace, search (shape), suppression vérifiée. Utilisateur +
|
||||||
|
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
|
||||||
|
d'un workspace actif (fallback « premier workspace du user ») — on ne le
|
||||||
|
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
|
||||||
|
- Suite complète : **1083/1083**
|
||||||
|
|
||||||
|
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les
|
||||||
|
garde-fous A16 — `test_smoke_uncovered.py` : 42 tests :
|
||||||
|
· `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` →
|
||||||
|
**403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit
|
||||||
|
dans le data_dir de test → **200 + octets exacts** (nettoyé)
|
||||||
|
· `GET /api/pages/{id}/download` : page markdown → 404 « downloadable »
|
||||||
|
(pas de 500) ; page « file » avec chemin `../` qui sort de la racine →
|
||||||
|
**jamais 200** (404), et `file-content` → 404/415
|
||||||
|
· `GET /api/local-workspace/page-content/{id}` : contenu + format relus,
|
||||||
|
404 sur id inconnu
|
||||||
|
· `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False`
|
||||||
|
(AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans
|
||||||
|
avatar
|
||||||
|
· `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape,
|
||||||
|
ligne créée **retrouvée dans table-data**, nettoyage finally
|
||||||
|
- Suite complète : **1079/1079**
|
||||||
|
|
||||||
|
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
|
||||||
|
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
|
||||||
|
du rendu (whitelist A2 vérifiée côté page)
|
||||||
|
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
|
||||||
|
restaurée en `finally`
|
||||||
|
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
|
||||||
|
la session seule ne change pas le mdp) + quirk assumé et documenté :
|
||||||
|
longueur validée AVANT auth → 200 + message d'erreur
|
||||||
|
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
|
||||||
|
nettoyée en `finally`
|
||||||
|
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
|
||||||
|
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
|
||||||
|
court** (validateur), restauration en `finally`
|
||||||
|
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
|
||||||
|
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
|
||||||
|
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
|
||||||
|
|
||||||
|
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
|
||||||
|
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
|
||||||
|
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
|
||||||
|
dans la liste, changement de couleur relu, suppression puis absence
|
||||||
|
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
|
||||||
|
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
|
||||||
|
(`parent_section='Trash'` + `deleted_at` **relus en base**)
|
||||||
|
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
|
||||||
|
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
|
||||||
|
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
|
||||||
|
· `workspace/{id}/members` : shape `{"members": [...]}`
|
||||||
|
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
|
||||||
|
- Suite complète : **1069/1069**
|
||||||
|
|
||||||
|
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
|
||||||
|
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
|
||||||
|
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
|
||||||
|
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
|
||||||
|
route/auth/parse, pas le moteur)
|
||||||
|
· `POST /properties/compute-rollup` : 400 `collection_id required`,
|
||||||
|
401 sans bearer
|
||||||
|
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
|
||||||
|
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
|
||||||
|
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
|
||||||
|
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
|
||||||
|
· `POST /webhooks/verify-signature` : **valid=True** avec
|
||||||
|
`sign_payload(secret, payload)` (même helper que le serveur), False avec
|
||||||
|
une signature bidon
|
||||||
|
- `test_smoke_uncovered.py` : 27 tests au total
|
||||||
|
- Suite complète : **1064/1064**
|
||||||
|
|
||||||
|
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
|
||||||
|
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
|
||||||
|
relecture du roundtrip
|
||||||
|
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
|
||||||
|
suppression vérifiée
|
||||||
|
· `card` POST : 404 sans board, `{"status": "ok"}` avec
|
||||||
|
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
|
||||||
|
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
|
||||||
|
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
|
||||||
|
· checklist mutations : PATCH item (checked/content relus EN BASE),
|
||||||
|
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
|
||||||
|
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
|
||||||
|
stub de transport httpx. Reste global : `dashboard.py` 17/63,
|
||||||
|
`api_v2.py` 50/115
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
|
||||||
|
|
||||||
|
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
|
||||||
|
(recents/favorites/published/private/workspace) en un test de boucle,
|
||||||
|
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
|
||||||
|
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
|
||||||
|
(aucun appel réseau — la clé n'est qu'une string de workspace)
|
||||||
|
- Test de non-régression 404 sur les 2 routes supprimées
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **2 routes cassées supprimées** (découverte des smokes) :
|
||||||
|
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
|
||||||
|
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
|
||||||
|
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
|
||||||
|
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
|
||||||
|
`library._format_size` devenu mort : supprimé aussi (une version vit dans
|
||||||
|
`dashboard.py`, inchangée)
|
||||||
|
- `local_workspace_items` : plus aucune occurrence dans `app/`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
|
||||||
|
|
||||||
|
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
|
||||||
|
routers qui n'avaient AUCUN test :
|
||||||
|
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
|
||||||
|
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
|
||||||
|
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
|
||||||
|
(zéro accès réseau réel)
|
||||||
|
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
|
||||||
|
échappement HTML vérifié `<b>`)
|
||||||
|
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
|
||||||
|
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
|
||||||
|
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
|
||||||
|
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
|
||||||
|
`dashboard.py` 17/63, `api_v2.py` 50/115
|
||||||
|
- Suite complète : **1047/1047**
|
||||||
|
|
||||||
|
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
|
||||||
|
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
|
||||||
|
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
|
||||||
|
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
|
||||||
|
(déterministe, équivalent sémantique de l'`await` d'avant). Note
|
||||||
|
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
|
||||||
|
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
|
||||||
|
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
|
||||||
|
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
|
||||||
|
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
|
||||||
|
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
|
||||||
|
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
|
||||||
|
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
|
||||||
|
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
|
||||||
|
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
|
||||||
|
en 228 s
|
||||||
|
|
||||||
|
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
|
||||||
|
était `body = await request.json()` passent à un paramètre FastAPI
|
||||||
|
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
|
||||||
|
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
|
||||||
|
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
|
||||||
|
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
|
||||||
|
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
|
||||||
|
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
|
||||||
|
il ne reste que **19 routes async** dans ce router (`fire_event`,
|
||||||
|
`request.form`, appels gitea/webhooks — phase 2b)
|
||||||
|
- Bug de transformation évité en cours de route : première version du script
|
||||||
|
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
|
||||||
|
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
|
||||||
|
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
|
||||||
|
`test_v65` + audit : 62/62 verts avant la passe complète
|
||||||
|
|
||||||
|
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties
|
||||||
|
en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail
|
||||||
|
SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une
|
||||||
|
ligne de logique** (la conversion est sémantiquement neutre : vérifié corps
|
||||||
|
par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les
|
||||||
|
fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25,
|
||||||
|
board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers
|
||||||
|
- **Reste (phase 2)** — les 311 routes qui ont de vrais `await`
|
||||||
|
(`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans
|
||||||
|
`await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour
|
||||||
|
l'instant (rien ne l'appellerait)
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les
|
||||||
|
handlers SQLite ne saturent plus la boucle pendant les tests
|
||||||
|
|
||||||
|
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
|
||||||
|
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
|
||||||
|
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
|
||||||
|
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
|
||||||
|
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
|
||||||
|
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
|
||||||
|
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
|
||||||
|
- Les 74 handlers `onclick=` inline restent fonctionnels via
|
||||||
|
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
|
||||||
|
aurait désactivés aussi)
|
||||||
|
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
|
||||||
|
`style-src` : les vues chart/map de `collections` les utilisent et étaient
|
||||||
|
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
|
||||||
|
vendoriser ces libs puis retirer les hôtes)
|
||||||
|
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
|
||||||
|
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
|
||||||
|
`img-src`/`connect-src`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
|
||||||
|
header identique sur tous les scripts inline, nonce différent d'une requête à
|
||||||
|
l'autre) et page hors template (`/auth/login?provider=local`)
|
||||||
|
|
||||||
|
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
|
||||||
|
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
|
||||||
|
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
|
||||||
|
laissait un schéma partiel commité SANS ligne de version, et la reprise
|
||||||
|
rejouait un DDL déjà appliqué
|
||||||
|
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
|
||||||
|
`ValueError` sinon) : **25 copies** de
|
||||||
|
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
|
||||||
|
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
|
||||||
|
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
|
||||||
|
se lit dans le set
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
|
||||||
|
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
|
||||||
|
|
||||||
|
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A29** — `services/publish.py` partagé : les 3 paires publish/unpublish
|
||||||
|
(sharing = front, board, v2) déléguent ; 404 partout (board faisait une
|
||||||
|
mise à jour aveugle), slugify titré unique (board : aléatoire ; v2 : slug
|
||||||
|
fourni conservé), événements centralisés, board gagne le contrôle de session.
|
||||||
|
Les bonus divergents disparaissent (`share_mode='anyone'` pour board,
|
||||||
|
`is_shared=1` pour v2) : le share dialog reste l'unique propriétaire de ces
|
||||||
|
drapeaux, dépublier ne révoque donc pas un partage manuel. Les listings
|
||||||
|
`/users/me` ×2 et collections ×3 restent : contrats versionnés distincts
|
||||||
|
- **A42** — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` →
|
||||||
|
`settings.data_dir` (property : lecture à chaque accès, les tests
|
||||||
|
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à
|
||||||
|
chaque écriture (il ne pouvait que grandir) ; les 29 `Environment(...)`
|
||||||
|
étaient déjà couverts par A10. **Reste** : client httpx partagé (52 créations,
|
||||||
|
à faire avec un cache par event loop)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Byproduct A29** — `GET /api/users/me` (v1) et le contexte Jinja de
|
||||||
|
`/accounts` renvoyaient `SELECT *` sur `users` : **password_hash**,
|
||||||
|
`login_attempts` et `locked_until` exposés → colonnes whitelistées
|
||||||
|
(identiques à la liste v2)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_publish_service_shared_and_safe` (slug, 404, partage préservé),
|
||||||
|
`test_users_me_no_secret_columns`, `test_gitea_cache_evicts_expired`
|
||||||
|
|
||||||
|
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
|
||||||
|
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
|
||||||
|
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
|
||||||
|
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
|
||||||
|
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
|
||||||
|
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
|
||||||
|
dérivées de `settings.app_base_url` + localhost/origines d'extension
|
||||||
|
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
|
||||||
|
explicite ; test `test_cors_no_star`
|
||||||
|
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
|
||||||
|
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
|
||||||
|
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
|
||||||
|
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
|
||||||
|
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
|
||||||
|
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
|
||||||
|
templates, JS, autres CSS et code Python
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
|
||||||
|
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
|
||||||
|
automatisé du view-switch
|
||||||
|
|
||||||
|
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
|
||||||
|
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
|
||||||
|
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
|
||||||
|
la valeur par défaut (il signe les sessions)
|
||||||
|
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
|
||||||
|
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
|
||||||
|
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
|
||||||
|
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
|
||||||
|
(croissance mémoire bornée)
|
||||||
|
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
|
||||||
|
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
|
||||||
|
scheduler passés en `warning`
|
||||||
|
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
|
||||||
|
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
|
||||||
|
dupliqué retiré du ROADMAP
|
||||||
|
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
|
||||||
|
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
|
||||||
|
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
|
||||||
|
(format ISO naïf identique, zéro changement de comportement)
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
|
||||||
|
l'alignement à 3.13 implique un rebuild d'image à valider
|
||||||
|
|
||||||
|
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
|
||||||
|
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
|
||||||
|
d'événements/webhooks et des écritures sont enfin visibles dans les logs
|
||||||
|
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
|
||||||
|
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
|
||||||
|
transaction au lieu de commiter une collection sans schéma
|
||||||
|
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
|
||||||
|
point d'entrée des connexions) ; le wrapper async + les 510 call sites
|
||||||
|
synchrones sur l'event loop restent à migrer
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
|
||||||
|
|
||||||
|
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
|
||||||
|
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
|
||||||
|
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
|
||||||
|
`ping()` du serveur vers un `api_base` interne
|
||||||
|
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
|
||||||
|
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
|
||||||
|
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
|
||||||
|
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
|
||||||
|
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
|
||||||
|
|
||||||
|
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
|
||||||
|
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
|
||||||
|
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
|
||||||
|
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
|
||||||
|
préfixes sortent d'`EXCLUDED_PATHS`
|
||||||
|
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
|
||||||
|
(interpolations Jinja neutralisées) — 0 échec avant/après
|
||||||
|
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
|
||||||
|
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
|
||||||
|
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
|
||||||
|
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
|
||||||
|
|
||||||
|
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
|
||||||
|
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
|
||||||
|
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
|
||||||
|
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
|
||||||
|
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
|
||||||
|
`welcome.html` équipés du header
|
||||||
|
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
|
||||||
|
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
|
||||||
|
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
|
||||||
|
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
|
||||||
|
route, pas le 403 du middleware) → suite **1026/1026**
|
||||||
|
|
||||||
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||||
|
|
||||||
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|||||||
+22
-26
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.3.4 (audit sécurité A1–A24 + A16) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.21.0 (audit — A27 partiel : 4 243 L extraites, 0 erreur eslint) | **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
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ def pkce_pair() -> tuple[str, str]:
|
|||||||
return verifier, challenge
|
return verifier, challenge
|
||||||
|
|
||||||
|
|
||||||
def _b64url(data: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def _b64url_decode(data: str) -> bytes:
|
def _b64url_decode(data: str) -> bytes:
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
@@ -31,7 +31,7 @@ class SessionManager:
|
|||||||
"""
|
"""
|
||||||
payload = {
|
payload = {
|
||||||
"user": user_data,
|
"user": user_data,
|
||||||
"created_at": datetime.utcnow().isoformat(),
|
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||||
}
|
}
|
||||||
user_id = user_data.get("id")
|
user_id = user_data.get("id")
|
||||||
if user_id:
|
if user_id:
|
||||||
@@ -94,7 +94,7 @@ class SessionManager:
|
|||||||
sid = SessionManager.session_id(cookie) if cookie else None
|
sid = SessionManager.session_id(cookie) if cookie else None
|
||||||
payload = {
|
payload = {
|
||||||
"user": user_data,
|
"user": user_data,
|
||||||
"created_at": datetime.utcnow().isoformat(),
|
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||||
}
|
}
|
||||||
user_id = user_data.get("id")
|
user_id = user_data.get("id")
|
||||||
if user_id:
|
if user_id:
|
||||||
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_touch_session")
|
||||||
|
|
||||||
|
|
||||||
# FastAPI dependency
|
# FastAPI dependency
|
||||||
|
|||||||
+13
-4
@@ -1,12 +1,22 @@
|
|||||||
"""FlowDeck — Configuration via pydantic-settings."""
|
"""FlowDeck — Configuration via pydantic-settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
@property
|
||||||
|
def data_dir(self) -> str:
|
||||||
|
"""Racine des fichiers (avatars, uploads…).
|
||||||
|
|
||||||
|
Pas un champ : la lecture est faite à chaque accès parce que les tests
|
||||||
|
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
|
||||||
|
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
|
||||||
|
"""
|
||||||
|
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||||
)
|
)
|
||||||
@@ -22,9 +32,6 @@ class Settings(BaseSettings):
|
|||||||
github_oauth_client_id: str = ""
|
github_oauth_client_id: str = ""
|
||||||
github_oauth_client_secret: str = ""
|
github_oauth_client_secret: str = ""
|
||||||
|
|
||||||
# Standalone mode
|
|
||||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
|
||||||
|
|
||||||
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||||
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||||
oauth_redirect_uri: str = ""
|
oauth_redirect_uri: str = ""
|
||||||
@@ -129,7 +136,9 @@ class Settings(BaseSettings):
|
|||||||
import re
|
import re
|
||||||
if re.match(r'^[a-zA-Z]:', p):
|
if re.match(r'^[a-zA-Z]:', p):
|
||||||
return Path(p)
|
return Path(p)
|
||||||
return Path("/" + p)
|
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
|
||||||
|
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
|
||||||
|
return Path("/" + p.lstrip("/"))
|
||||||
return Path("/data/flowdeck.db")
|
return Path("/data/flowdeck.db")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -837,6 +837,11 @@ def get_conn():
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
|
||||||
|
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
|
||||||
|
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
|
||||||
|
# l'event loop) reste à migrer module par module.
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+64
-17
@@ -71,6 +71,31 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(name: str, factory):
|
||||||
|
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
|
||||||
|
|
||||||
|
Loggue l'exception puis recrée la coroutine 10 s plus tard.
|
||||||
|
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
|
||||||
|
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
|
||||||
|
le bruit devient un problème.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _guard():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await factory()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
else:
|
||||||
|
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
return asyncio.create_task(_guard())
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
@@ -81,6 +106,13 @@ async def lifespan(_app: FastAPI):
|
|||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.password_utils import hash_password
|
from app.password_utils import hash_password
|
||||||
|
|
||||||
|
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
|
||||||
|
if settings.app_secret_key == "change-me-to-random":
|
||||||
|
raise RuntimeError(
|
||||||
|
"APP_SECRET_KEY non défini — générer une valeur : "
|
||||||
|
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
|
||||||
|
)
|
||||||
|
|
||||||
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
||||||
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -99,41 +131,41 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||||
from app.routers.agent import agent_scheduler
|
from app.routers.agent import agent_scheduler
|
||||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
|
||||||
|
|
||||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||||
from app.services.automations import automation_scheduler
|
from app.services.automations import automation_scheduler
|
||||||
automation_task = asyncio.create_task(automation_scheduler())
|
automation_task = _spawn("automation_scheduler", automation_scheduler)
|
||||||
|
|
||||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||||
from app.services.backup import backup_scheduler
|
from app.services.backup import backup_scheduler
|
||||||
backup_task = asyncio.create_task(backup_scheduler())
|
backup_task = _spawn("backup_scheduler", backup_scheduler)
|
||||||
|
|
||||||
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
||||||
from app.services.projects import project_sync_scheduler
|
from app.services.projects import project_sync_scheduler
|
||||||
projects_task = asyncio.create_task(project_sync_scheduler())
|
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
|
||||||
|
|
||||||
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
||||||
from app.services.trash import trash_purge_scheduler
|
from app.services.trash import trash_purge_scheduler
|
||||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
|
||||||
|
|
||||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||||
from app.services.reminders import reminder_scheduler
|
from app.services.reminders import reminder_scheduler
|
||||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||||
|
|
||||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||||
from app.services.semantic_search import semantic_index_scheduler
|
from app.services.semantic_search import semantic_index_scheduler
|
||||||
semantic_task = asyncio.create_task(semantic_index_scheduler())
|
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||||
|
|
||||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||||
from app.services.calendar_sync import calendar_sync_scheduler
|
from app.services.calendar_sync import calendar_sync_scheduler
|
||||||
calendar_task = asyncio.create_task(calendar_sync_scheduler())
|
calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
|
||||||
|
|
||||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||||
webhook_task = None
|
webhook_task = None
|
||||||
if settings.webhook_retry_enabled:
|
if settings.webhook_retry_enabled:
|
||||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
|
||||||
|
|
||||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||||
try:
|
try:
|
||||||
@@ -153,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.3.4",
|
version="7.21.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -163,7 +195,22 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
|
|||||||
app.add_middleware(CSRFMiddleware)
|
app.add_middleware(CSRFMiddleware)
|
||||||
app.add_middleware(ContentSecurityPolicyMiddleware)
|
app.add_middleware(ContentSecurityPolicyMiddleware)
|
||||||
app.add_middleware(RateLimitMiddleware)
|
app.add_middleware(RateLimitMiddleware)
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
# A37 : origines explicites (l'auth est un cookie de session ; le front est
|
||||||
|
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
|
||||||
|
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
|
||||||
|
_CORS_ORIGINS = sorted(
|
||||||
|
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
|
||||||
|
)
|
||||||
|
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
||||||
|
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_CORS_ORIGINS,
|
||||||
|
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
|
||||||
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
|
||||||
|
allow_credentials=True,
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(sso_router)
|
app.include_router(sso_router)
|
||||||
@@ -218,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")
|
||||||
@@ -235,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
|
||||||
|
|
||||||
@@ -250,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)
|
||||||
@@ -259,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)
|
||||||
@@ -294,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
|
||||||
|
|||||||
+15
-1
@@ -18,7 +18,21 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
||||||
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
||||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"}
|
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
||||||
|
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
||||||
|
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
||||||
|
# library, gitea_workspace, workspace, workspaces, welcome).
|
||||||
|
# Ne restent que du machine-to-machine / hors session :
|
||||||
|
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
|
||||||
|
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
|
||||||
|
# - publics : /s/ (sites), /f/ (forms)
|
||||||
|
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
||||||
|
EXCLUDED_PATHS = {
|
||||||
|
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
|
||||||
|
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
|
||||||
|
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
|
||||||
|
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
||||||
|
}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -8,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
|
||||||
@@ -62,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:; "
|
||||||
@@ -77,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
|
||||||
|
|
||||||
|
|
||||||
@@ -97,8 +117,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
# Paths that should be rate-limited
|
# Paths that should be rate-limited
|
||||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||||
"/api/", "/board/api/", "/auth/",
|
"/api/", "/board/api/", "/auth/",
|
||||||
|
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
||||||
|
# API workspace + collections (les endpoints mutants du legacy).
|
||||||
|
"/scim/v2/", "/workspace/", "/db/",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pages publiques : seul le non-GET est plafonné (brute force de
|
||||||
|
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
||||||
|
# visiteurs d'un site publié qui partagent une IP.
|
||||||
|
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
||||||
|
|
||||||
# Paths exempt from rate limiting even under an API prefix
|
# Paths exempt from rate limiting even under an API prefix
|
||||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||||
"/api/health",
|
"/api/health",
|
||||||
@@ -106,11 +134,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
"/api/frontend-errors",
|
"/api/frontend-errors",
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
|
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
||||||
|
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
||||||
self.max_requests = max_requests
|
self.max_requests = max_requests
|
||||||
self.window_seconds = window_seconds
|
self.window_seconds = window_seconds
|
||||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||||
|
self._last_prune = 0.0
|
||||||
|
self._max_keys = 5000
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
@@ -120,27 +152,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
if not settings.rate_limit_enabled:
|
if not settings.rate_limit_enabled:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Only rate-limit API routes
|
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
||||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
method = request.method.upper()
|
||||||
|
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
||||||
|
method not in ("GET", "HEAD", "OPTIONS")
|
||||||
|
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
||||||
|
)
|
||||||
|
if not limited:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Exempt health check and error capture
|
# Exempt health check and error capture
|
||||||
if path in self.EXEMPT_PATHS:
|
if path in self.EXEMPT_PATHS:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
ip = request.client.host if request.client else "unknown"
|
limit = self.max_requests or settings.rate_limit_requests
|
||||||
|
ip = self._client_key(request)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
||||||
|
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
||||||
|
self._prune(now)
|
||||||
|
|
||||||
window_start, count = self._store[ip]
|
window_start, count = self._store[ip]
|
||||||
if now - window_start > self.window_seconds:
|
if now - window_start > self.window_seconds:
|
||||||
self._store[ip] = (now, 1)
|
self._store[ip] = (now, 1)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
if count >= self.max_requests:
|
if count >= limit:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
||||||
status_code=429,
|
status_code=429,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._store[ip] = (window_start, count + 1)
|
self._store[ip] = (window_start, count + 1)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
def _client_key(self, request: Request) -> str:
|
||||||
|
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
||||||
|
|
||||||
|
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
||||||
|
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
||||||
|
internaute, donc le XFF du proxy fait foi.
|
||||||
|
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
||||||
|
exposée directement), prendre la dernière adresse non privée de la
|
||||||
|
chaîne plutôt que la première.
|
||||||
|
"""
|
||||||
|
host = request.client.host if request.client else "unknown"
|
||||||
|
fwd = request.headers.get("x-forwarded-for", "")
|
||||||
|
if fwd:
|
||||||
|
try:
|
||||||
|
direct = ipaddress.ip_address(host)
|
||||||
|
if direct.is_private or direct.is_loopback:
|
||||||
|
return fwd.split(",")[0].strip() or host
|
||||||
|
except ValueError:
|
||||||
|
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
||||||
|
return host
|
||||||
|
|
||||||
|
def _prune(self, now: float) -> None:
|
||||||
|
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
||||||
|
for k in expired:
|
||||||
|
del self._store[k]
|
||||||
|
self._last_prune = now
|
||||||
|
|||||||
+62
-29
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
|
||||||
|
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
|
||||||
|
|
||||||
|
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
|
||||||
|
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
|
||||||
|
unitaire se lit déjà dans le set.
|
||||||
|
"""
|
||||||
|
if not table.replace("_", "").isalnum():
|
||||||
|
raise ValueError(f"nom de table invalide: {table!r}")
|
||||||
|
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
|
||||||
|
|
||||||
def current_version(conn: sqlite3.Connection) -> int:
|
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:
|
||||||
@@ -862,7 +895,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
|||||||
try:
|
try:
|
||||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_migration_v630_api_v2")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
|||||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
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")
|
||||||
|
|
||||||
|
|||||||
+1
-18
@@ -1,10 +1,9 @@
|
|||||||
"""FlowDeck — Pydantic request models for API validation."""
|
"""FlowDeck — Pydantic request models for API validation."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import UploadFile
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
|
from app.middleware.security import ALLOWED_EXTENSIONS, _ext
|
||||||
|
|
||||||
# ── File Save ────────────────────────────────────────────────
|
# ── File Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def validate_upload_request(file: UploadFile) -> str | None:
|
|
||||||
"""Validate an uploaded file (size + extension). Returns error message or None."""
|
|
||||||
# Size check — we can't read the full file without a size attribute,
|
|
||||||
# but Starlette's UploadFile has a size property from Content-Length
|
|
||||||
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
|
|
||||||
return f"File '{file.filename}' exceeds maximum size of 10 MB"
|
|
||||||
|
|
||||||
# Extension check
|
|
||||||
if file.filename:
|
|
||||||
ext = _ext(file.filename)
|
|
||||||
if ext and ext not in ALLOWED_EXTENSIONS:
|
|
||||||
return f"File extension '{ext}' is not allowed"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Issue Create / Update ────────────────────────────────────
|
|
||||||
|
|
||||||
class IssueCreateRequest(BaseModel):
|
class IssueCreateRequest(BaseModel):
|
||||||
"""Request model for creating a Gitea issue."""
|
"""Request model for creating a Gitea issue."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+54
-34
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
|
|||||||
triggers = conn.execute(
|
triggers = conn.execute(
|
||||||
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
|
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
now = datetime.utcnow()
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
for trig in triggers:
|
for trig in triggers:
|
||||||
last = trig["last_fired_at"]
|
last = trig["last_fired_at"]
|
||||||
if last:
|
if last:
|
||||||
@@ -92,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
|
|||||||
logger.exception("Agent scheduler tick failed")
|
logger.exception("Agent scheduler tick failed")
|
||||||
|
|
||||||
|
|
||||||
async def _current_user_id(request: Request) -> int | None:
|
async def _current_user_id(request: Request) -> int:
|
||||||
|
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||||
user = await get_current_user(request)
|
user = await get_current_user(request)
|
||||||
if user and user.get("id"):
|
if not user or not user.get("id"):
|
||||||
return user["id"]
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
with get_conn() as conn:
|
return user["id"]
|
||||||
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
|
||||||
return row["id"] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def _workspace_id(request: Request) -> int | None:
|
async def _workspace_id(request: Request) -> int | None:
|
||||||
@@ -113,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _current_admin(request: Request) -> dict:
|
async def _current_admin(request: Request) -> dict:
|
||||||
"""Require an admin session. Falls back to the single admin row, matching
|
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||||
the agent router's unauthenticated convention (single-user deployments)."""
|
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||||
|
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||||
user = await get_current_user(request)
|
user = await get_current_user(request)
|
||||||
if user:
|
if not user:
|
||||||
if not user.get("is_admin"):
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
from app.db import get_conn as _gc
|
if not user.get("is_admin"):
|
||||||
with _gc() as conn:
|
from app.db import get_conn as _gc
|
||||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
with _gc() as conn:
|
||||||
if not row or not row["is_admin"]:
|
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
if not row or not row["is_admin"]:
|
||||||
return user
|
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||||
with get_conn() as conn:
|
return user
|
||||||
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
|
||||||
if not row or not row["is_admin"]:
|
|
||||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_agent(conn, user_id: int) -> dict:
|
def _default_agent(conn, user_id: int) -> dict:
|
||||||
@@ -216,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:
|
||||||
@@ -229,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")
|
||||||
@@ -453,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",
|
||||||
@@ -463,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:
|
||||||
@@ -531,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"}
|
||||||
@@ -580,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()
|
||||||
@@ -590,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:
|
||||||
@@ -797,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}
|
||||||
@@ -997,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
|||||||
return {"ok": False, "provider": provider, "error": str(exc)}
|
return {"ok": False, "provider": provider, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_api_base(value: str) -> str:
|
||||||
|
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
|
||||||
|
|
||||||
|
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
|
||||||
|
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
|
||||||
|
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
|
||||||
|
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
|
||||||
|
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
|
||||||
|
settings `llm_allow_private=false`.
|
||||||
|
"""
|
||||||
|
url = (value or "").strip()
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/providers")
|
@router.patch("/providers")
|
||||||
async def update_provider_config(request: Request):
|
async def update_provider_config(request: Request):
|
||||||
await _current_admin(request)
|
await _current_admin(request)
|
||||||
@@ -1008,7 +1028,7 @@ async def update_provider_config(request: Request):
|
|||||||
provider=provider or None,
|
provider=provider or None,
|
||||||
model=(body.get("model") or "").strip() or None,
|
model=(body.get("model") or "").strip() or None,
|
||||||
api_key=body.get("api_key"),
|
api_key=body.get("api_key"),
|
||||||
api_base=(body.get("api_base") or "").strip() or None,
|
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||||
clear_keys=(provider == "offline"),
|
clear_keys=(provider == "offline"),
|
||||||
)
|
)
|
||||||
llm = LLMClient()
|
llm = LLMClient()
|
||||||
@@ -1037,7 +1057,7 @@ async def test_provider_config(request: Request):
|
|||||||
llm = LLMClient(
|
llm = LLMClient(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
api_key=body.get("api_key"),
|
api_key=body.get("api_key"),
|
||||||
api_base=(body.get("api_base") or "").strip() or None,
|
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||||
@@ -1061,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:
|
||||||
@@ -1096,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:
|
||||||
|
|||||||
+24
-19
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -46,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
|
|||||||
if not settings.rate_limit_enabled:
|
if not settings.rate_limit_enabled:
|
||||||
return True
|
return True
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
now = datetime.utcnow().timestamp()
|
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
|
||||||
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
||||||
if now - window_start > 60:
|
if now - window_start > 60:
|
||||||
_rate_limit_store[ip] = (now, 1)
|
_rate_limit_store[ip] = (now, 1)
|
||||||
@@ -67,12 +67,12 @@ async def health(request: Request):
|
|||||||
conn.execute("SELECT 1")
|
conn.execute("SELECT 1")
|
||||||
db_ok = True
|
db_ok = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("health")
|
||||||
try:
|
try:
|
||||||
await gitea.get_user_repos(page=1, limit=1)
|
await gitea.get_user_repos(page=1, limit=1)
|
||||||
gitea_ok = True
|
gitea_ok = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("health")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||||
@@ -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,14 +545,19 @@ 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:
|
||||||
return {"login": "guest", "full_name": "Guest", "email": ""}
|
return {"login": "guest", "full_name": "Guest", "email": ""}
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
|
||||||
|
# password_hash, login_attempts et locked_until.
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||||
|
"is_admin, is_active, last_login, created_at "
|
||||||
|
"FROM users WHERE login=?",
|
||||||
|
(user.get("login", ""),),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
return dict(row)
|
return dict(row)
|
||||||
@@ -560,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", ""))
|
||||||
@@ -579,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,
|
||||||
@@ -668,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:
|
||||||
|
|||||||
+240
-580
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()
|
||||||
|
|||||||
+20
-10
@@ -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"])
|
||||||
@@ -573,4 +583,4 @@ def _log_login(user_id: int, request: Request):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_log_login")
|
||||||
|
|||||||
@@ -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,))
|
||||||
|
|||||||
+42
-65
@@ -10,12 +10,14 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.routers.dashboard import _get_app_version
|
from app.routers.dashboard import _get_app_version
|
||||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||||
from app.services.automations import fire_event
|
from app.services.automations import fire_event
|
||||||
from app.services.gitea_client import gitea
|
from app.services.gitea_client import gitea
|
||||||
from app.services.permission_manager import PermissionManager
|
from app.services.permission_manager import PermissionManager
|
||||||
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["board"], prefix="/board")
|
router = APIRouter(tags=["board"], prefix="/board")
|
||||||
@@ -64,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()
|
||||||
@@ -96,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(","):
|
||||||
@@ -177,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")
|
||||||
@@ -695,7 +697,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
local_ws_id = row["id"]
|
local_ws_id = row["id"]
|
||||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_sidebar_data")
|
||||||
elif ws_cookie and user:
|
elif ws_cookie and user:
|
||||||
try:
|
try:
|
||||||
wsi = int(ws_cookie)
|
wsi = int(ws_cookie)
|
||||||
@@ -781,7 +783,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
elif t["provider"] == "github":
|
elif t["provider"] == "github":
|
||||||
github_linked = True
|
github_linked = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||||
"active_ws_name": active_ws_name,
|
"active_ws_name": active_ws_name,
|
||||||
@@ -850,15 +852,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
|
|
||||||
"""Return groups from Gitea labels/milestones or fallback to defaults."""
|
|
||||||
try:
|
|
||||||
labels = json.loads(
|
|
||||||
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
|
|
||||||
) if False else []
|
|
||||||
except Exception:
|
|
||||||
labels = []
|
|
||||||
return labels if labels else ["Design", "Engineering", "No Team"]
|
|
||||||
|
|
||||||
|
|
||||||
async def asyncio_get_labels(owner: str, repo: str):
|
async def asyncio_get_labels(owner: str, repo: str):
|
||||||
@@ -906,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
|
||||||
@@ -950,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
|
||||||
@@ -982,7 +975,7 @@ async def add_favorite(request: Request, page_id: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("add_favorite")
|
||||||
return {"status": "added", "page_id": page_id}
|
return {"status": "added", "page_id": page_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -997,7 +990,7 @@ async def remove_favorite(request: Request, page_id: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("remove_favorite")
|
||||||
return {"status": "removed", "page_id": page_id}
|
return {"status": "removed", "page_id": page_id}
|
||||||
|
|
||||||
# ═══════════ Share API ═══════════
|
# ═══════════ Share API ═══════════
|
||||||
@@ -1020,42 +1013,27 @@ async def update_share(request: Request, page_id: int):
|
|||||||
@router.post("/api/pages/{page_id:int}/publish")
|
@router.post("/api/pages/{page_id:int}/publish")
|
||||||
async def publish_page(request: Request, page_id: int):
|
async def publish_page(request: Request, page_id: int):
|
||||||
"""Publish a page to the web (generates publish_slug)."""
|
"""Publish a page to the web (generates publish_slug)."""
|
||||||
import secrets
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||||
slug = "p-" + secrets.token_urlsafe(8)
|
raise HTTPException(401, "Authentication required")
|
||||||
with get_conn() as conn:
|
slug, title = publish(page_id)
|
||||||
conn.execute(
|
await fire_published(page_id, slug)
|
||||||
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
|
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||||
(slug, page_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
||||||
try:
|
|
||||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/pages/{page_id:int}/publish")
|
@router.delete("/api/pages/{page_id:int}/publish")
|
||||||
async def unpublish_page(request: Request, page_id: int):
|
async def unpublish_page(request: Request, page_id: int):
|
||||||
"""Unpublish a page from the web."""
|
"""Unpublish a page from the web."""
|
||||||
with get_conn() as conn:
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||||
conn.execute(
|
raise HTTPException(401, "Authentication required")
|
||||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
unpublish(page_id)
|
||||||
(page_id,),
|
await fire_unpublished(page_id)
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
try:
|
|
||||||
await fire_event("page.unpublished", {"page_id": page_id})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"is_published": False}
|
return {"is_published": False}
|
||||||
|
|
||||||
|
|
||||||
# ═══════════ 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]
|
||||||
@@ -1069,12 +1047,12 @@ async def restore_page(request: Request, page_id: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("page.restored", {"page_id": page_id})
|
await fire_event("page.restored", {"page_id": page_id})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("restore_page")
|
||||||
return {"status": "ok", "restored": page_id}
|
return {"status": "ok", "restored": page_id}
|
||||||
|
|
||||||
|
|
||||||
@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,))
|
||||||
@@ -1083,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")
|
||||||
@@ -1094,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
|
||||||
@@ -1169,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)
|
||||||
@@ -1195,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)
|
||||||
@@ -1203,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)}
|
||||||
@@ -1212,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)
|
||||||
@@ -1301,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()])
|
||||||
@@ -1323,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=?",
|
||||||
@@ -1334,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(
|
||||||
@@ -1354,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",
|
||||||
@@ -1417,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"):
|
||||||
@@ -1565,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
|
||||||
@@ -1613,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(
|
||||||
@@ -1695,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def _upload_root() -> Path:
|
def _upload_root() -> Path:
|
||||||
import os
|
return Path(settings.data_dir)
|
||||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
|
||||||
|
|
||||||
|
|
||||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||||
@@ -1732,7 +1709,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
|||||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||||
raise HTTPException(400, "Unsupported image format")
|
raise HTTPException(400, "Unsupported image format")
|
||||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
final = f"{stamp}_{name}"
|
final = f"{stamp}_{name}"
|
||||||
@@ -1773,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()
|
||||||
@@ -2040,14 +2017,14 @@ async def delete_page(request: Request, page_id: int):
|
|||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Page not found")
|
raise HTTPException(404, "Page not found")
|
||||||
import datetime
|
import datetime
|
||||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -129,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
|
|||||||
if mentioned_ids:
|
if mentioned_ids:
|
||||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("add_comment")
|
||||||
|
|
||||||
return {"id": comment_id, "status": "created"}
|
return {"id": comment_id, "status": "created"}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
|
|||||||
try:
|
try:
|
||||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("notify_page_mentions")
|
||||||
return {"mentioned": mentioned}
|
return {"mentioned": mentioned}
|
||||||
|
|
||||||
|
|
||||||
@@ -190,12 +190,12 @@ async def update_comment(request: Request, comment_id: int):
|
|||||||
try:
|
try:
|
||||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("update_comment")
|
||||||
return {"id": comment_id, "status": "updated"}
|
return {"id": comment_id, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
@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))
|
||||||
|
|||||||
+61
-60
@@ -2,11 +2,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ def _get_user_or_redirect(request: Request):
|
|||||||
if count == 0:
|
if count == 0:
|
||||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_get_user_or_redirect")
|
||||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
avatar_url = row["avatar_url"] or ""
|
avatar_url = row["avatar_url"] or ""
|
||||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
recent_pages = []
|
recent_pages = []
|
||||||
for repo in repos[:10]:
|
for repo in repos[:10]:
|
||||||
@@ -179,7 +181,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
elif t["provider"] == "github":
|
elif t["provider"] == "github":
|
||||||
github_linked = True
|
github_linked = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
# Get local workspace ID for Gitea workspace mirror
|
# Get local workspace ID for Gitea workspace mirror
|
||||||
local_ws_id = 0
|
local_ws_id = 0
|
||||||
@@ -193,7 +195,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
if row:
|
if row:
|
||||||
local_ws_id = row["id"]
|
local_ws_id = row["id"]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
# Private pages for mirror workspace (when Gitea remote active)
|
# Private pages for mirror workspace (when Gitea remote active)
|
||||||
private_pages = []
|
private_pages = []
|
||||||
@@ -206,7 +208,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||||
shared_made_pages = []
|
shared_made_pages = []
|
||||||
@@ -250,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
|
||||||
@@ -277,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.
|
||||||
@@ -306,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"
|
||||||
@@ -432,21 +434,24 @@ 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
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
|
users = conn.execute(
|
||||||
|
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||||
|
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||||
template = env.get_template("accounts.html")
|
template = env.get_template("accounts.html")
|
||||||
return template.render(**ctx)
|
return template.render(**ctx)
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
@@ -651,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
|
||||||
@@ -726,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)
|
||||||
@@ -740,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(
|
||||||
@@ -771,7 +776,7 @@ async def dashboard(
|
|||||||
template = env.get_template("landing.html")
|
template = env.get_template("landing.html")
|
||||||
return template.render()
|
return template.render()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("dashboard")
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
template = env.get_template("landing.html")
|
template = env.get_template("landing.html")
|
||||||
@@ -788,7 +793,7 @@ async def dashboard(
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
has_gitea = bool(tok)
|
has_gitea = bool(tok)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("dashboard")
|
||||||
|
|
||||||
if not has_gitea:
|
if not has_gitea:
|
||||||
# Check if user has any workspace
|
# Check if user has any workspace
|
||||||
@@ -801,7 +806,7 @@ async def dashboard(
|
|||||||
# v5.2.0: first-launch → onboarding wizard
|
# v5.2.0: first-launch → onboarding wizard
|
||||||
return RedirectResponse("/welcome", status_code=302)
|
return RedirectResponse("/welcome", status_code=302)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("dashboard")
|
||||||
return RedirectResponse("/local-workspace", status_code=302)
|
return RedirectResponse("/local-workspace", status_code=302)
|
||||||
|
|
||||||
# ── Gitea user → full dashboard ──
|
# ── Gitea user → full dashboard ──
|
||||||
@@ -829,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
|
||||||
@@ -846,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
|
||||||
|
|
||||||
@@ -942,7 +947,7 @@ async def list_workspace_projects(request: Request):
|
|||||||
"forge": "gitea",
|
"forge": "gitea",
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("list_workspace_projects")
|
||||||
|
|
||||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||||
|
|
||||||
@@ -966,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(
|
||||||
@@ -1017,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(
|
||||||
@@ -1031,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.
|
||||||
@@ -1079,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.
|
||||||
@@ -1104,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(
|
||||||
@@ -1142,9 +1147,8 @@ def _file_page_disk_path(page: dict):
|
|||||||
parts = rel.split("/")
|
parts = rel.split("/")
|
||||||
if ".." in parts or "." in parts:
|
if ".." in parts or "." in parts:
|
||||||
return None
|
return None
|
||||||
import os as _os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
root = Path(settings.data_dir).resolve()
|
||||||
full = (root / rel).resolve()
|
full = (root / rel).resolve()
|
||||||
try:
|
try:
|
||||||
full.relative_to(root)
|
full.relative_to(root)
|
||||||
@@ -1170,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:
|
||||||
@@ -1193,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
|
||||||
@@ -1221,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)
|
||||||
@@ -1389,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
|
||||||
@@ -1479,20 +1483,20 @@ 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:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||||
(datetime.utcnow().isoformat(), item_id),
|
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
@@ -1504,12 +1508,11 @@ 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
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
root = Path(settings.data_dir)
|
||||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||||
fp = (base_dir / filename).resolve()
|
fp = (base_dir / filename).resolve()
|
||||||
try:
|
try:
|
||||||
@@ -1567,11 +1570,10 @@ async def upload_local_workspace_file(request: Request):
|
|||||||
if not files:
|
if not files:
|
||||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.middleware.security import validate_upload
|
from app.middleware.security import validate_upload
|
||||||
|
|
||||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
data_root = Path(settings.data_dir)
|
||||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -1655,11 +1657,10 @@ async def upload_local_workspace_folder(request: Request):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.middleware.security import validate_upload
|
from app.middleware.security import validate_upload
|
||||||
|
|
||||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
data_root = Path(settings.data_dir)
|
||||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -1773,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
|
||||||
@@ -1790,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
|
||||||
@@ -1854,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,))
|
||||||
@@ -1865,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})
|
||||||
@@ -1876,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
|
||||||
@@ -1922,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
|
||||||
|
|
||||||
@@ -1941,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()
|
||||||
@@ -2001,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:
|
||||||
@@ -2012,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:
|
||||||
@@ -2031,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
|
||||||
@@ -2054,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(
|
||||||
@@ -2097,13 +2098,13 @@ async def add_item_tag(request: Request, item_id: int):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("add_item_tag")
|
||||||
|
|
||||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
@@ -2115,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
|
||||||
@@ -2197,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
|
||||||
@@ -2259,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
|
||||||
|
|
||||||
@@ -2524,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(
|
||||||
@@ -2562,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(
|
||||||
@@ -2640,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(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|
||||||
router = APIRouter(tags=["emojis"])
|
router = APIRouter(tags=["emojis"])
|
||||||
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
|||||||
|
|
||||||
|
|
||||||
def _upload_root() -> Path:
|
def _upload_root() -> Path:
|
||||||
import os
|
|
||||||
|
|
||||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
return Path(settings.data_dir)
|
||||||
|
|
||||||
|
|
||||||
def _active_ws(request: Request) -> int:
|
def _active_ws(request: Request) -> int:
|
||||||
@@ -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:
|
||||||
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
|
|||||||
if ext not in _IMAGE_EXTS:
|
if ext not in _IMAGE_EXTS:
|
||||||
raise HTTPException(400, "Unsupported image format")
|
raise HTTPException(400, "Unsupported image format")
|
||||||
ws_id = _active_ws(request)
|
ws_id = _active_ws(request)
|
||||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
|
||||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
final = f"emoji_{stamp}_{safe}"
|
final = f"emoji_{stamp}_{safe}"
|
||||||
@@ -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"
|
||||||
|
|||||||
+5
-13
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def _require_user_gitea(request: Request):
|
|
||||||
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
|
|
||||||
from app.services.gitea_client import get_user_gitea_client
|
|
||||||
client = get_user_gitea_client(request)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
# ── Orgs ──
|
|
||||||
@router.get("/orgs")
|
@router.get("/orgs")
|
||||||
async def list_orgs(request: Request):
|
async def list_orgs(request: Request):
|
||||||
"""List organizations the user belongs to."""
|
"""List organizations the user belongs to."""
|
||||||
@@ -170,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)
|
||||||
@@ -178,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
|
||||||
@@ -194,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
|
||||||
@@ -233,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
|
||||||
@@ -280,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).
|
||||||
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
|
|||||||
try:
|
try:
|
||||||
await websocket.close(code=4401)
|
await websocket.close(code=4401)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("ws_page")
|
||||||
return
|
return
|
||||||
|
|
||||||
conn = await manager.connect(websocket, page_id, user)
|
conn = await manager.connect(websocket, page_id, user)
|
||||||
|
|||||||
+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:
|
||||||
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
|
|||||||
try:
|
try:
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("revoke_session")
|
||||||
return {"status": "revoked"}
|
return {"status": "revoked"}
|
||||||
|
|||||||
+11
-64
@@ -2,15 +2,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
from datetime import UTC, datetime
|
||||||
import unicodedata
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.automations import fire_event as _fire_event
|
from app.services.automations import fire_event as _fire_event
|
||||||
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||||
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _slugify(title: str) -> str:
|
|
||||||
"""Generate a URL-safe slug from a page title."""
|
|
||||||
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
|
||||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
|
||||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
|
||||||
return slug or "untitled"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Page Sharing ──
|
# ── Page Sharing ──
|
||||||
|
|
||||||
|
|
||||||
@@ -130,7 +121,7 @@ async def share_page(page_id: int, request: Request):
|
|||||||
try:
|
try:
|
||||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("share_page")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": share_id,
|
"id": share_id,
|
||||||
@@ -213,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)
|
||||||
|
|
||||||
@@ -247,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)
|
||||||
|
|
||||||
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
|
|||||||
async def publish_page(page_id: int, request: Request):
|
async def publish_page(page_id: int, request: Request):
|
||||||
"""Publish a page (is_published=1) with a URL slug."""
|
"""Publish a page (is_published=1) with a URL slug."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
|
slug, _title = publish(page_id)
|
||||||
with get_conn() as conn:
|
await fire_published(page_id, slug)
|
||||||
page = conn.execute(
|
|
||||||
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(404, "Page not found")
|
|
||||||
|
|
||||||
slug = _slugify(page["title"])
|
|
||||||
# Ensure uniqueness by appending suffix if needed
|
|
||||||
base_slug = slug
|
|
||||||
counter = 1
|
|
||||||
while conn.execute(
|
|
||||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
|
||||||
).fetchone():
|
|
||||||
slug = f"{base_slug}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
|
|
||||||
(slug, page_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"page_id": page_id,
|
"page_id": page_id,
|
||||||
"is_published": True,
|
"is_published": True,
|
||||||
@@ -346,25 +310,8 @@ async def publish_page(page_id: int, request: Request):
|
|||||||
async def unpublish_page(page_id: int, request: Request):
|
async def unpublish_page(page_id: int, request: Request):
|
||||||
"""Unpublish a page."""
|
"""Unpublish a page."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
|
unpublish(page_id)
|
||||||
with get_conn() as conn:
|
await fire_unpublished(page_id)
|
||||||
page = conn.execute(
|
|
||||||
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(404, "Page not found")
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
|
||||||
(page_id,),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"page_id": page_id,
|
"page_id": page_id,
|
||||||
"is_published": False,
|
"is_published": False,
|
||||||
@@ -399,7 +346,7 @@ async def track_recent(request: Request):
|
|||||||
DO UPDATE SET workspace=excluded.workspace,
|
DO UPDATE SET workspace=excluded.workspace,
|
||||||
source_type=excluded.source_type,
|
source_type=excluded.source_type,
|
||||||
accessed_at=excluded.accessed_at""",
|
accessed_at=excluded.accessed_at""",
|
||||||
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -407,5 +354,5 @@ async def track_recent(request: Request):
|
|||||||
"status": "tracked",
|
"status": "tracked",
|
||||||
"user_id": user["id"],
|
"user_id": user["id"],
|
||||||
"page_id": page_id,
|
"page_id": page_id,
|
||||||
"accessed_at": datetime.utcnow().isoformat(),
|
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+21
-16
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
@@ -35,6 +36,8 @@ from app.services.api_v2_helpers import (
|
|||||||
row_to_dict,
|
row_to_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["sites"])
|
router = APIRouter(tags=["sites"])
|
||||||
|
|
||||||
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
|
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
|
||||||
@@ -160,7 +163,7 @@ def _render_page_html(page: dict) -> str:
|
|||||||
from app.services.synced_blocks import resolve_synced_block
|
from app.services.synced_blocks import resolve_synced_block
|
||||||
blocks = resolve_synced_block(blocks)
|
blocks = resolve_synced_block(blocks)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_render_page_html")
|
||||||
titles: dict = {}
|
titles: dict = {}
|
||||||
try:
|
try:
|
||||||
from app.db import get_conn as _gc
|
from app.db import get_conn as _gc
|
||||||
@@ -223,7 +226,7 @@ def _track_view(site_id: int) -> None:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_track_view")
|
||||||
|
|
||||||
|
|
||||||
def _form_config(conn, collection_id: int) -> dict:
|
def _form_config(conn, collection_id: int) -> dict:
|
||||||
@@ -312,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:
|
||||||
@@ -330,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()
|
||||||
@@ -404,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()
|
||||||
@@ -419,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()
|
||||||
@@ -458,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()
|
||||||
@@ -475,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:
|
||||||
@@ -513,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", ""))
|
||||||
@@ -534,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"):
|
||||||
@@ -553,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:
|
||||||
@@ -589,12 +592,14 @@ async def public_site_auth(request: Request, slug: str):
|
|||||||
try:
|
try:
|
||||||
password = (await request.json()).get("password", "")
|
password = (await request.json()).get("password", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("public_site_auth")
|
||||||
password = ""
|
password = ""
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
password = form.get("password", "")
|
password = form.get("password", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("public_site_auth")
|
||||||
password = ""
|
password = ""
|
||||||
if not verify_password(password or "", site["password_hash"] or ""):
|
if not verify_password(password or "", site["password_hash"] or ""):
|
||||||
raise HTTPException(401, "Wrong password")
|
raise HTTPException(401, "Wrong password")
|
||||||
@@ -609,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)
|
||||||
@@ -657,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()
|
||||||
@@ -781,7 +786,7 @@ async def submit_form(request: Request, token: str):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("submit_form")
|
||||||
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
|
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
|
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
|
||||||
@@ -808,12 +813,12 @@ async def submit_form(request: Request, token: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("submit_form")
|
||||||
try:
|
try:
|
||||||
from app.services.automations import fire_event as _fire
|
from app.services.automations import fire_event as _fire
|
||||||
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("submit_form")
|
||||||
if "application/json" in ctype:
|
if "application/json" in ctype:
|
||||||
return {"status": "ok", "row_id": row_id,
|
return {"status": "ok", "row_id": row_id,
|
||||||
"message": cfg.get("success_message") or "Merci !"}
|
"message": cfg.get("success_message") or "Merci !"}
|
||||||
|
|||||||
+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")
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ async def clip_page(request: Request):
|
|||||||
if isinstance(_imgs, list) and _imgs:
|
if isinstance(_imgs, list) and _imgs:
|
||||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("clip_page")
|
||||||
clip_data = {
|
clip_data = {
|
||||||
"url": url,
|
"url": url,
|
||||||
"title": title[:200],
|
"title": title[:200],
|
||||||
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
|
|||||||
try:
|
try:
|
||||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("clip_page")
|
||||||
|
|
||||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ async def extensions_page(request: Request):
|
|||||||
devices = list_devices(user["id"])
|
devices = list_devices(user["id"])
|
||||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("extensions_page")
|
||||||
content_html = f"""
|
content_html = f"""
|
||||||
<style>
|
<style>
|
||||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+24
-24
@@ -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",
|
||||||
@@ -160,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("add_comment")
|
||||||
return {"id": cur.lastrowid, "status": "created"}
|
return {"id": cur.lastrowid, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
@@ -180,14 +180,14 @@ async def update_comment(request: Request, comment_id: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("update_comment")
|
||||||
return {"status": "updated"}
|
return {"status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
# ── 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:
|
||||||
@@ -246,12 +246,12 @@ async def add_favorite(request: Request):
|
|||||||
try:
|
try:
|
||||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("add_favorite")
|
||||||
return {"status": "favorited"}
|
return {"status": "favorited"}
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
@@ -493,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("create_sprint")
|
||||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
@@ -523,12 +523,12 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
|||||||
try:
|
try:
|
||||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("update_sprint")
|
||||||
return {"id": sid, "status": "updated"}
|
return {"id": sid, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def to_iso8601(value: str | None) -> str | None:
|
def to_iso8601(value: str | None) -> str | None:
|
||||||
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
|
|||||||
try:
|
try:
|
||||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("row_to_dict")
|
||||||
return d
|
return d
|
||||||
|
|
||||||
# ── Pagination ────────────────────────────────────────────────────────────
|
# ── Pagination ────────────────────────────────────────────────────────────
|
||||||
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
|||||||
if dt.timestamp() < time.time():
|
if dt.timestamp() < time.time():
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("resolve_bearer_token")
|
||||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||||
if u:
|
if u:
|
||||||
d = dict(u)
|
d = dict(u)
|
||||||
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
|||||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("resolve_bearer_token")
|
||||||
return d
|
return d
|
||||||
# 2) extension_devices
|
# 2) extension_devices
|
||||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||||
@@ -211,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def require_scope(required: str):
|
def require_scope(required: str):
|
||||||
|
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
|
||||||
|
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
|
||||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||||
user = get_bearer_user(request, authorization)
|
user = get_bearer_user(request, authorization)
|
||||||
scopes = user.get("_token_scopes") or "read"
|
# Pas de default "read" : identique au contrôle manuel des handlers
|
||||||
|
# (un jeton sans scope est refusé, quel que soit le scope demandé).
|
||||||
|
scopes = user.get("_token_scopes")
|
||||||
if not has_scope(scopes, required):
|
if not has_scope(scopes, required):
|
||||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
||||||
return user
|
return user
|
||||||
@@ -257,7 +264,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("audit_log")
|
||||||
|
|
||||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -307,4 +314,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("store_idempotency")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -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).
|
||||||
@@ -420,7 +433,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
|
|||||||
expr = (expression or "").strip().lower()
|
expr = (expression or "").strip().lower()
|
||||||
if not expr:
|
if not expr:
|
||||||
return False
|
return False
|
||||||
now = now or datetime.utcnow()
|
now = now or datetime.now(UTC).replace(tzinfo=None)
|
||||||
minute = now.minute
|
minute = now.minute
|
||||||
fields = expr.split()
|
fields = expr.split()
|
||||||
|
|
||||||
@@ -488,7 +501,7 @@ async def automation_scheduler():
|
|||||||
from app.services.workers import run_due_workers
|
from app.services.workers import run_due_workers
|
||||||
await run_due_workers()
|
await run_due_workers()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.debug("worker cron iteration failed")
|
logger.warning("worker cron iteration failed")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.warning("automation_scheduler iteration failed")
|
logger.warning("automation_scheduler iteration failed")
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
|
|||||||
with sqlite3.connect(str(db_path)) as conn:
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("backup_db")
|
||||||
|
|
||||||
dest_dir = _backup_dir()
|
dest_dir = _backup_dir()
|
||||||
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
|
|||||||
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
||||||
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.debug("calendar_sync_scheduler: %s", exc)
|
logger.warning("calendar_sync_scheduler: %s", exc)
|
||||||
await asyncio.sleep(interval_seconds)
|
await asyncio.sleep(interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|
||||||
# ═══════════════ Helpers ═══════════════
|
# ═══════════════ Helpers ═══════════════
|
||||||
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
|
|||||||
|
|
||||||
def _data_root() -> Path:
|
def _data_root() -> Path:
|
||||||
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
||||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
return Path(settings.data_dir)
|
||||||
|
|
||||||
|
|
||||||
def _file_meta(page: dict) -> dict:
|
def _file_meta(page: dict) -> dict:
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ class GiteaClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _set_cache(self, key: str, value: Any) -> None:
|
def _set_cache(self, key: str, value: Any) -> None:
|
||||||
self._cache[key] = (datetime.now() + self._ttl, value)
|
now = datetime.now()
|
||||||
|
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
|
||||||
|
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
|
||||||
|
self._cache[key] = (now + self._ttl, value)
|
||||||
|
|
||||||
# ── repos ──
|
# ── repos ──
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
|
|||||||
if langs:
|
if langs:
|
||||||
repo_info["language"] = max(langs, key=langs.get)
|
repo_info["language"] = max(langs, key=langs.get)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("get_repo_info")
|
||||||
|
|
||||||
self._set_cache(cache_key, repo_info)
|
self._set_cache(cache_key, repo_info)
|
||||||
return repo_info
|
return repo_info
|
||||||
|
|||||||
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
|
|||||||
return [str(value)]
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
def strip_markdown(text: str) -> str:
|
|
||||||
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
|
|
||||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
|
|
||||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
|
||||||
text = re.sub(r"[*_~#>]+", "", text)
|
|
||||||
return text.strip()
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.db_templates import materialize_properties
|
from app.services.db_templates import materialize_properties
|
||||||
from app.services.export import markdown_to_blocks
|
from app.services.export import markdown_to_blocks
|
||||||
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
|||||||
|
|
||||||
|
|
||||||
def _data_dir() -> Path:
|
def _data_dir() -> Path:
|
||||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
return Path(settings.data_dir)
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename(name: str) -> str:
|
def _safe_filename(name: str) -> str:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -29,7 +30,7 @@ class TranscriptionUnavailable(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def meetings_dir() -> Path:
|
def meetings_dir() -> Path:
|
||||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
root = Path(settings.data_dir)
|
||||||
d = root / "uploads" / "meetings"
|
d = root / "uploads" / "meetings"
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -247,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
|
|
||||||
"""Compute the value of an auto-property."""
|
|
||||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
|
||||||
return datetime.now(UTC).isoformat()
|
|
||||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
|
||||||
return user_ref(user)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def apply_auto_properties(
|
def apply_auto_properties(
|
||||||
@@ -291,28 +284,5 @@ def apply_auto_properties(
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
def get_next_unique_id(collection_id: int, conn) -> int:
|
|
||||||
"""Get the next unique_id for a collection (max + 1)."""
|
|
||||||
row = conn.execute(
|
|
||||||
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
|
|
||||||
FROM collection_pages WHERE collection_id=?""",
|
|
||||||
(collection_id,),
|
|
||||||
).fetchone()
|
|
||||||
return row[0] if row else 1
|
|
||||||
|
|
||||||
|
|
||||||
def format_number(value: float, fmt: str = "number") -> str:
|
|
||||||
"""Format a number value for display."""
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if fmt == "percent":
|
|
||||||
return f"{value}%"
|
|
||||||
elif fmt == "dollar":
|
|
||||||
return f"${value:,.2f}"
|
|
||||||
elif fmt == "euro":
|
|
||||||
return f"€{value:,.2f}"
|
|
||||||
elif fmt == "pound":
|
|
||||||
return f"£{value:,.2f}"
|
|
||||||
elif fmt == "yen":
|
|
||||||
return f"¥{value:,.0f}"
|
|
||||||
return str(value)
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
|
||||||
|
|
||||||
|
Les trois surfaces divergeaient avant cette passe :
|
||||||
|
|
||||||
|
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
|
||||||
|
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
|
||||||
|
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
|
||||||
|
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
|
||||||
|
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
||||||
|
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||||
|
|
||||||
|
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
||||||
|
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
||||||
|
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
||||||
|
donc pas un partage manuel.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services.automations import fire_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(title: str) -> str:
|
||||||
|
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
|
||||||
|
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
||||||
|
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||||
|
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_slug(conn, page_id: int, title: str) -> str:
|
||||||
|
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
|
||||||
|
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
|
||||||
|
slug, counter = base, 1
|
||||||
|
while conn.execute(
|
||||||
|
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||||
|
).fetchone():
|
||||||
|
slug = f"{base}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
||||||
|
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
page = conn.execute(
|
||||||
|
"SELECT id, title FROM pages WHERE id=?", (page_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return slug, page["title"] or ""
|
||||||
|
|
||||||
|
|
||||||
|
def unpublish(page_id: int) -> None:
|
||||||
|
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def fire_published(page_id: int, slug: str) -> None:
|
||||||
|
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
|
||||||
|
try:
|
||||||
|
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("publish page.published")
|
||||||
|
|
||||||
|
|
||||||
|
async def fire_unpublished(page_id: int) -> None:
|
||||||
|
try:
|
||||||
|
await fire_event("page.unpublished", {"page_id": page_id})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("publish page.unpublished")
|
||||||
@@ -349,7 +349,7 @@ class RealtimeManager:
|
|||||||
try:
|
try:
|
||||||
await conn.ws.close(code=4413)
|
await conn.ws.close(code=4413)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("_evict_slow")
|
||||||
|
|
||||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||||
|
|||||||
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
|
|||||||
return dt.datetime.now(dt.UTC)
|
return dt.datetime.now(dt.UTC)
|
||||||
|
|
||||||
|
|
||||||
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
|
|
||||||
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
|
|
||||||
return now_in_tz(tz_name).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
|
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
|
||||||
|
|||||||
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("purge_stale_requests")
|
||||||
|
|||||||
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
|
|||||||
|
|
||||||
# ── Unsync: convert synced block to independent copy ──────────────
|
# ── Unsync: convert synced block to independent copy ──────────────
|
||||||
|
|
||||||
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
|
|
||||||
"""Remove a page's sync reference and return the current content
|
|
||||||
so the caller can turn it into an independent block."""
|
|
||||||
sb = get_synced_block(synced_block_id)
|
|
||||||
if not sb:
|
|
||||||
return None
|
|
||||||
remove_page_synced(page_id, synced_block_id)
|
|
||||||
try:
|
|
||||||
return json.loads(sb["content"])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
|
|||||||
return ToolResult(status="error", tool=self.name,
|
return ToolResult(status="error", tool=self.name,
|
||||||
message=f"Document #{pid} introuvable")
|
message=f"Document #{pid} introuvable")
|
||||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||||
(datetime.utcnow().isoformat(), pid))
|
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
|
|||||||
|
|
||||||
Returns a summary of what was purged.
|
Returns a summary of what was purged.
|
||||||
"""
|
"""
|
||||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
|
||||||
purged: list[int] = []
|
purged: list[int] = []
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
|
|||||||
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
|
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
|
||||||
|
|
||||||
|
|
||||||
def verify_device_token(device_id: str, token: str) -> dict | None:
|
|
||||||
"""Verify a device token, returns device row or None."""
|
|
||||||
thash = _hash_token(token)
|
|
||||||
with get_conn() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
|
|
||||||
(device_id, thash),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
|
||||||
conn.commit()
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
|
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
|
||||||
|
|||||||
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def find_referring(content: str, page_id: int) -> bool:
|
|
||||||
"""True when the content references ``page_id`` (anchor or wiki token)."""
|
|
||||||
if not content:
|
|
||||||
return False
|
|
||||||
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
|
|
||||||
|
|||||||
@@ -15,172 +15,7 @@
|
|||||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
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() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
|
||||||
/* ═════════════════════════════════════════════════════════════════════
|
|
||||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
|
||||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
|
||||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
|
||||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
|
||||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
|
||||||
─────────────────────────────────────────────────────────────────── */
|
|
||||||
(function () {
|
|
||||||
if (window.__fdCtxMenuRegistered) return;
|
|
||||||
window.__fdCtxMenuRegistered = true;
|
|
||||||
|
|
||||||
// Sur un chargement complet de page, ce script inline s'exécute AVANT
|
|
||||||
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
|
|
||||||
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
|
|
||||||
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
|
|
||||||
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
|
|
||||||
function registerFdCtx() {
|
|
||||||
if (!window.Alpine) return;
|
|
||||||
if (window.Alpine.__fdCtxStore) return;
|
|
||||||
window.Alpine.__fdCtxStore = true;
|
|
||||||
|
|
||||||
Alpine.store('fdCtx', {
|
|
||||||
open: false, x: 0, y: 0, node: null, page: null,
|
|
||||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
|
||||||
handlers: {},
|
|
||||||
/* Tags (workspace) */
|
|
||||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
|
||||||
newTagColor: '#787774',
|
|
||||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
|
||||||
/* Icon picker */
|
|
||||||
iconOpen: false,
|
|
||||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
|
||||||
|
|
||||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
|
||||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
|
||||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
|
||||||
openMenu(ev, node, pageHash, handlers) {
|
|
||||||
handlers = handlers || {};
|
|
||||||
this.node = node;
|
|
||||||
this.page = pageHash;
|
|
||||||
this.handlers = handlers;
|
|
||||||
this.tagAdding = false;
|
|
||||||
this.tagExistingOpen = false;
|
|
||||||
this.iconOpen = false;
|
|
||||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
|
||||||
var cx = (ev && ev.clientX) || 0;
|
|
||||||
var cy = (ev && ev.clientY) || 0;
|
|
||||||
this._cx = cx;
|
|
||||||
this._cy = cy;
|
|
||||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
|
||||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
|
||||||
this.open = true;
|
|
||||||
var self = this;
|
|
||||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
|
||||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
|
||||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
|
||||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
|
||||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
|
||||||
var after = function () {
|
|
||||||
self._place();
|
|
||||||
var el = document.querySelector('.fd-ctx-menu');
|
|
||||||
if (el && window.ResizeObserver) {
|
|
||||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
|
||||||
self._ro = new ResizeObserver(function () { self._place(); });
|
|
||||||
self._ro.observe(el);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
|
||||||
else setTimeout(after, 0);
|
|
||||||
},
|
|
||||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
|
||||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
|
||||||
_place() {
|
|
||||||
var el = document.querySelector('.fd-ctx-menu');
|
|
||||||
if (!el) return;
|
|
||||||
var prev = el.style.maxHeight;
|
|
||||||
el.style.maxHeight = 'none';
|
|
||||||
var w = el.offsetWidth || 240;
|
|
||||||
var h = el.offsetHeight || 320;
|
|
||||||
el.style.maxHeight = prev || '';
|
|
||||||
var vw = window.innerWidth, vh = window.innerHeight;
|
|
||||||
var cx = (this._cx == null ? this.x : this._cx);
|
|
||||||
var cy = (this._cy == null ? this.y : this._cy);
|
|
||||||
var nx = cx;
|
|
||||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
|
||||||
nx = Math.max(8, nx);
|
|
||||||
var ny = cy;
|
|
||||||
if (ny + h > vh - 8) {
|
|
||||||
if (cy - h >= 8) ny = cy - h;
|
|
||||||
else ny = Math.max(8, vh - h - 8);
|
|
||||||
}
|
|
||||||
this.x = nx;
|
|
||||||
this.y = ny;
|
|
||||||
this.maxH = Math.max(120, vh - ny - 8);
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
|
||||||
this.open = false;
|
|
||||||
this.node = null;
|
|
||||||
this.handlers = {};
|
|
||||||
this.tagAdding = false;
|
|
||||||
this.tagExistingOpen = false;
|
|
||||||
this.iconOpen = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
|
||||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
|
||||||
|
|
||||||
/* Exécute l'action → handler fourni par la page courante. */
|
|
||||||
run(key) {
|
|
||||||
if (!this.node) { this.close(); return; }
|
|
||||||
var fn = this.handlers[key];
|
|
||||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ── Tags ── */
|
|
||||||
avail() { return this.availTags || []; },
|
|
||||||
setAvail(a) { this.availTags = a || []; },
|
|
||||||
removeTag(id) {
|
|
||||||
var fn = this.handlers.tagRemove;
|
|
||||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
|
||||||
},
|
|
||||||
addExistingTag(t) {
|
|
||||||
var fn = this.handlers.tagExisting;
|
|
||||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
|
||||||
this.tagExistingOpen = false;
|
|
||||||
},
|
|
||||||
addNewTag(name, color) {
|
|
||||||
name = (name || '').trim();
|
|
||||||
if (!name) return;
|
|
||||||
var fn = this.handlers.tagAdd;
|
|
||||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
|
||||||
this.tagAdding = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ── Icon picker ── */
|
|
||||||
setIcon(icon) {
|
|
||||||
icon = (icon || '').trim();
|
|
||||||
var fn = this.handlers.setIcon;
|
|
||||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
openIconPicker() {
|
|
||||||
var fn = this.handlers.setIcon;
|
|
||||||
if (!fn) return;
|
|
||||||
if (!window.FDIconPicker) return;
|
|
||||||
window.FDIconPicker.openFor({
|
|
||||||
x: this.x,
|
|
||||||
y: this.y,
|
|
||||||
onPick: fn,
|
|
||||||
onRemove: function () { fn(''); }
|
|
||||||
});
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.Alpine) {
|
|
||||||
registerFdCtx();
|
|
||||||
} else {
|
|
||||||
document.addEventListener('alpine:init', registerFdCtx);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
||||||
|
|||||||
@@ -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,306 +10,9 @@
|
|||||||
############################################################################}
|
############################################################################}
|
||||||
{% 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() }}" src="/static/js/_icon_picker_1.js?v={{ asset_version }}"></script>
|
||||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
|
||||||
(function () {
|
|
||||||
var FD_ICONS = {
|
|
||||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
|
||||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
|
||||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
|
||||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
|
||||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
|
||||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
|
||||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
|
||||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
|
||||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
||||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
|
||||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
|
||||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
|
||||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
||||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
|
||||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
|
||||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
|
||||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
||||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
||||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
|
||||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
|
||||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
|
||||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
|
||||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
|
||||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
|
||||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
|
||||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
|
||||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
|
||||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
|
||||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
|
||||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
|
||||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
|
||||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
|
||||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
|
||||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
|
||||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
|
||||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
|
||||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
|
||||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
|
||||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
|
||||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
|
||||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
|
||||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
|
||||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
|
||||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
|
||||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
|
||||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
|
||||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
|
||||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
|
||||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
|
||||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
|
||||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
|
||||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
||||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
|
||||||
};
|
|
||||||
window.FD_ICONS = FD_ICONS;
|
|
||||||
window.fd_icon = function (name, size) {
|
|
||||||
size = size || 18;
|
|
||||||
var inner = FD_ICONS[name];
|
|
||||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
|
||||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
|
||||||
};
|
|
||||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
|
||||||
window.fdIconHtml = function (value, size) {
|
|
||||||
size = size || 16;
|
|
||||||
var v = (value == null ? '' : String(value)).trim();
|
|
||||||
if (!v) return '';
|
|
||||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
|
||||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
|
||||||
}
|
|
||||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
|
||||||
return v;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script data-cfasync="false">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_2.js?v={{ asset_version }}"></script>
|
||||||
(function () {
|
|
||||||
if (window.__fdIconPickerRegistered) return;
|
|
||||||
window.__fdIconPickerRegistered = true;
|
|
||||||
|
|
||||||
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
|
||||||
|
|
||||||
var TONEABLE = {};
|
|
||||||
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
|
|
||||||
|
|
||||||
var CATS = [
|
|
||||||
{ id: 'people', label: 'People', items: [
|
|
||||||
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
|
|
||||||
]},
|
|
||||||
{ id: 'nature', label: 'Nature', items: [
|
|
||||||
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
|
|
||||||
]},
|
|
||||||
{ id: 'food', label: 'Food', items: [
|
|
||||||
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
|
|
||||||
]},
|
|
||||||
{ id: 'activity', label: 'Activity', items: [
|
|
||||||
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
|
|
||||||
]},
|
|
||||||
{ id: 'travel', label: 'Travel', items: [
|
|
||||||
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
|
|
||||||
]},
|
|
||||||
{ id: 'objects', label: 'Objects', items: [
|
|
||||||
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
|
|
||||||
]},
|
|
||||||
{ id: 'symbols', label: 'Symbols', items: [
|
|
||||||
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
|
|
||||||
]},
|
|
||||||
{ id: 'flags', label: 'Flags', items: [
|
|
||||||
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️🌈','rainbow flag pride'],['🏴☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
|
|
||||||
]}
|
|
||||||
];
|
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () {
|
|
||||||
if (window.Alpine && window.Alpine.__fdIconPicker) return;
|
|
||||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
|
||||||
|
|
||||||
Alpine.store('fdIconPicker', {
|
|
||||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
|
||||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
|
||||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
|
||||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
|
||||||
cats: CATS,
|
|
||||||
|
|
||||||
openFor(opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
this.onPick = opts.onPick || null;
|
|
||||||
this.onRemove = opts.onRemove || null;
|
|
||||||
this.query = '';
|
|
||||||
this.toneOpen = false;
|
|
||||||
this.customModal = false;
|
|
||||||
this._cx = (opts.x != null) ? opts.x : 240;
|
|
||||||
this._cy = (opts.y != null) ? opts.y : 200;
|
|
||||||
this.x = this._cx;
|
|
||||||
this.y = this._cy;
|
|
||||||
this.open = true;
|
|
||||||
var self = this;
|
|
||||||
var place = function () {
|
|
||||||
var el = document.querySelector('.fd-icon-picker');
|
|
||||||
if (!el) return;
|
|
||||||
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
|
|
||||||
var vw = window.innerWidth, vh = window.innerHeight;
|
|
||||||
var nx = self._cx, ny = self._cy;
|
|
||||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
|
||||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
|
||||||
self.x = Math.max(8, nx);
|
|
||||||
self.y = Math.max(8, ny);
|
|
||||||
};
|
|
||||||
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
|
|
||||||
else setTimeout(place, 0);
|
|
||||||
this.loadRecent();
|
|
||||||
this.loadCustom();
|
|
||||||
},
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.open = false;
|
|
||||||
this.customModal = false;
|
|
||||||
this.toneOpen = false;
|
|
||||||
this.onPick = null;
|
|
||||||
this.onRemove = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
matches(name) {
|
|
||||||
var q = (this.query || '').trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return name.toLowerCase().indexOf(q) >= 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
items() {
|
|
||||||
var q = (this.query || '').trim().toLowerCase();
|
|
||||||
if (q) {
|
|
||||||
var out = [];
|
|
||||||
this.cats.forEach(function (c) {
|
|
||||||
c.items.forEach(function (it) {
|
|
||||||
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
if (this.category === 'recent') return this.recent;
|
|
||||||
var found = [];
|
|
||||||
for (var i = 0; i < this.cats.length; i++) {
|
|
||||||
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
},
|
|
||||||
|
|
||||||
withTone(e) {
|
|
||||||
if (!this.skinTone) return e;
|
|
||||||
if (TONEABLE[e]) return e + TONES[this.skinTone];
|
|
||||||
return e;
|
|
||||||
},
|
|
||||||
|
|
||||||
pick(v) {
|
|
||||||
v = (v || '').trim();
|
|
||||||
if (!v) return;
|
|
||||||
this.pushRecent(v);
|
|
||||||
var fn = this.onPick;
|
|
||||||
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
var fn = this.onRemove || this.onPick;
|
|
||||||
if (fn) { try { fn(''); } catch (e) {} }
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
|
|
||||||
random() {
|
|
||||||
var pool = [];
|
|
||||||
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
|
|
||||||
if (!pool.length) return;
|
|
||||||
this.pick(pool[Math.floor(Math.random() * pool.length)]);
|
|
||||||
},
|
|
||||||
|
|
||||||
setTone(i) { this.skinTone = i; this.toneOpen = false; },
|
|
||||||
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
|
|
||||||
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
|
|
||||||
|
|
||||||
loadRecent() {
|
|
||||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
|
||||||
catch (e) { this.recent = []; }
|
|
||||||
},
|
|
||||||
pushRecent(e) {
|
|
||||||
try {
|
|
||||||
var r = this.recent.filter(function (x) { return x !== e; });
|
|
||||||
r.unshift(e);
|
|
||||||
this.recent = r.slice(0, 32);
|
|
||||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
|
||||||
} catch (err) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadCustom() {
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
|
|
||||||
var d = await r.json();
|
|
||||||
this.custom = (d && d.emojis) || [];
|
|
||||||
this.customLoaded = true;
|
|
||||||
} catch (e) { this.custom = []; }
|
|
||||||
},
|
|
||||||
|
|
||||||
openCustomModal() {
|
|
||||||
this.customModal = true;
|
|
||||||
this.customName = '';
|
|
||||||
this.customPreview = '';
|
|
||||||
this.customFile = null;
|
|
||||||
this.tab = 'upload';
|
|
||||||
},
|
|
||||||
closeCustomModal() { this.customModal = false; },
|
|
||||||
onCustomFile(ev) {
|
|
||||||
var f = ev.target.files && ev.target.files[0];
|
|
||||||
if (!f) return;
|
|
||||||
this.customFile = f;
|
|
||||||
var self = this;
|
|
||||||
var fr = new FileReader();
|
|
||||||
fr.onload = function () { self.customPreview = fr.result; };
|
|
||||||
fr.readAsDataURL(f);
|
|
||||||
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
|
|
||||||
},
|
|
||||||
async saveCustom() {
|
|
||||||
if (!this.customFile || this.customBusy) return;
|
|
||||||
this.customBusy = true;
|
|
||||||
try {
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('name', this.customName || 'emoji');
|
|
||||||
fd.append('file', this.customFile);
|
|
||||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
||||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
|
||||||
var d = await r.json();
|
|
||||||
if (d && d.emoji) {
|
|
||||||
this.custom.unshift(d.emoji);
|
|
||||||
this.customModal = false;
|
|
||||||
this.pick(d.emoji.url);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
|
||||||
}
|
|
||||||
this.customBusy = false;
|
|
||||||
},
|
|
||||||
async deleteCustom(id) {
|
|
||||||
try {
|
|
||||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
||||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
|
||||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.FDIconPicker = {
|
|
||||||
openFor: function (opts) {
|
|
||||||
var s = window.Alpine && Alpine.store('fdIconPicker');
|
|
||||||
if (s) s.openFor(opts);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
|
|||||||
+2
-1807
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -10,9 +10,9 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
|
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
|
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
|
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||||
<style>
|
<style>
|
||||||
/* ── Mobile responsive (v4.0.2) ── */
|
/* ── Mobile responsive (v4.0.2) ── */
|
||||||
@@ -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 = () => {
|
||||||
@@ -2129,7 +2132,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
|
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||||
@@ -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; } },
|
||||||
@@ -2377,8 +2380,8 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
{# ─── 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=6.0.0" defer data-cfasync="false"></script>
|
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||||
<script data-cfasync="false">
|
<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'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{# Card detail modal content — full issue info + comments #}
|
{# Card detail modal content — full issue info + comments #}
|
||||||
|
{% from '_icons.html' import fd_icon %}
|
||||||
<div class="card-detail" x-data="cardDetail()">
|
<div class="card-detail" x-data="cardDetail()">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
|
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
|
||||||
@@ -98,7 +99,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,633 +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() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
Alpine.data('giteaWorkspace', () => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const owner = params.get('owner') || '';
|
|
||||||
const repo = params.get('repo') || '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
owner, repo,
|
|
||||||
showFile: false,
|
|
||||||
showEditor: false,
|
|
||||||
showPrivate: false,
|
|
||||||
privatePages: [],
|
|
||||||
editingPrivate: null,
|
|
||||||
editingPrivateTitle: '',
|
|
||||||
editingPrivateContent: '',
|
|
||||||
filePath: '',
|
|
||||||
fileContent: '',
|
|
||||||
fileSize: 0,
|
|
||||||
fileSha: '',
|
|
||||||
fileLoading: false,
|
|
||||||
fileLanguage: 'text',
|
|
||||||
editContent: '',
|
|
||||||
commitMessage: 'Update via FlowDeck',
|
|
||||||
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
|
||||||
// File tree browser
|
|
||||||
treeItems: [],
|
|
||||||
sortedTreeItems: [],
|
|
||||||
treeLoading: false,
|
|
||||||
folderStack: [],
|
|
||||||
currentPath: '',
|
|
||||||
// Context menu
|
|
||||||
gwCtx: { visible: false, x: 0, y: 0, item: null },
|
|
||||||
|
|
||||||
_sortTree() {
|
|
||||||
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
|
|
||||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
|
||||||
return a.type === 'folder' ? -1 : 1;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Expose globally for header to access
|
|
||||||
window._gwData = this;
|
|
||||||
// Set cookie for sidebar
|
|
||||||
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
|
||||||
// Load sidebar tree (into gitea section)
|
|
||||||
this.loadSidebarTree();
|
|
||||||
// Load main content tree
|
|
||||||
this.loadMainTree('');
|
|
||||||
// Listen for sidebar clicks on Gitea items
|
|
||||||
this.setupSidebarClicks();
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadMainTree(path) {
|
|
||||||
this.treeLoading = true;
|
|
||||||
this.currentPath = path;
|
|
||||||
try {
|
|
||||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var r = await fetch(url);
|
|
||||||
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
|
|
||||||
var d = await r.json();
|
|
||||||
this.treeItems = d.tree || [];
|
|
||||||
this._sortTree();
|
|
||||||
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
|
||||||
finally { this.treeLoading = false; }
|
|
||||||
},
|
|
||||||
|
|
||||||
drillDown(path) {
|
|
||||||
this.folderStack.push(path.split('/').pop());
|
|
||||||
this.loadMainTree(path);
|
|
||||||
},
|
|
||||||
|
|
||||||
navigateToFolder(idx) {
|
|
||||||
// Truncate stack and rebuild path
|
|
||||||
this.folderStack = this.folderStack.slice(0, idx + 1);
|
|
||||||
var path = this.folderStack.join('/');
|
|
||||||
this.loadMainTree(path);
|
|
||||||
},
|
|
||||||
|
|
||||||
navigateToRoot() {
|
|
||||||
this.folderStack = [];
|
|
||||||
this.loadMainTree('');
|
|
||||||
},
|
|
||||||
|
|
||||||
openGwContext(ev, item) {
|
|
||||||
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
|
|
||||||
},
|
|
||||||
gwRename() {
|
|
||||||
this.gwCtx.visible = false;
|
|
||||||
var item = this.gwCtx.item;
|
|
||||||
if (!item) return;
|
|
||||||
var newName = prompt('Rename:', item.name);
|
|
||||||
if (!newName || !newName.trim() || newName.trim() === item.name) return;
|
|
||||||
var self = this;
|
|
||||||
var oldPath = item.path;
|
|
||||||
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
|
||||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
|
||||||
}).then(function(r){ return r.json(); })
|
|
||||||
.then(function(d){
|
|
||||||
if (d.status === 'ok') { self.refreshTree(); }
|
|
||||||
else { window.showToast('Rename failed', 'error'); }
|
|
||||||
})
|
|
||||||
.catch(function(){ window.showToast('Rename failed', 'error'); });
|
|
||||||
},
|
|
||||||
gwDelete() {
|
|
||||||
this.gwCtx.visible = false;
|
|
||||||
var item = this.gwCtx.item;
|
|
||||||
if (!item) return;
|
|
||||||
if (!confirm('Delete ' + item.name + '?')) return;
|
|
||||||
var self = this;
|
|
||||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(function(r){
|
|
||||||
if (r.ok) { self.refreshTree(); }
|
|
||||||
else { window.showToast('Delete failed', 'error'); }
|
|
||||||
}).catch(function(){ window.showToast('Delete failed', 'error'); });
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSidebarTree() {
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
|
|
||||||
if (!r.ok) {
|
|
||||||
// If API fails (e.g. no Gitea token), set a message
|
|
||||||
var container = document.getElementById('sidebar-gitea-items');
|
|
||||||
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var d = await r.json();
|
|
||||||
var items = d.tree || [];
|
|
||||||
var container = document.getElementById('sidebar-gitea-items');
|
|
||||||
if (!container) return;
|
|
||||||
// Ensure gitea section is visible
|
|
||||||
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
|
|
||||||
window.appState.sectionsOpen.gitea = true;
|
|
||||||
}
|
|
||||||
container.innerHTML = '';
|
|
||||||
// Build tree HTML as string and set once (more performant)
|
|
||||||
var html = '';
|
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
var item = items[i];
|
|
||||||
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
|
||||||
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
|
||||||
html += '<span class="page-icon">' + icon + '</span>';
|
|
||||||
html += '<span class="page-name">' + item.name + '</span>';
|
|
||||||
html += '</li>';
|
|
||||||
}
|
|
||||||
// Add Private Pages link
|
|
||||||
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
|
||||||
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
|
||||||
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
// Re-attach event listeners
|
|
||||||
this.setupSidebarClicks();
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Gitea sidebar tree load failed:', e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setupSidebarClicks() {
|
|
||||||
var self = this;
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var el = e.target.closest('.sidebar-item[data-gitea-path]');
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
var path = el.getAttribute('data-gitea-path');
|
|
||||||
var type = el.getAttribute('data-gitea-type');
|
|
||||||
|
|
||||||
// If clicking the checkbox, let its own handler deal with selection
|
|
||||||
if (e.target.classList.contains('gitea-checkbox')) return;
|
|
||||||
|
|
||||||
// If clicking the inline rename input, don't navigate
|
|
||||||
if (e.target.classList.contains('inline-rename-input')) return;
|
|
||||||
|
|
||||||
// If Shift or Ctrl/Meta is pressed, use multi-selection
|
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
self.selectItem(path, el, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal click: navigate (open file/folder)
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Update visual "active" state
|
|
||||||
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
|
|
||||||
si.classList.remove('active');
|
|
||||||
});
|
|
||||||
el.classList.add('active');
|
|
||||||
|
|
||||||
if (type === 'folder') {
|
|
||||||
self.loadSubdir(path, el);
|
|
||||||
} else {
|
|
||||||
self.openFile(path, el.getAttribute('data-gitea-sha'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for custom delete event on gitea sidebar items
|
|
||||||
document.addEventListener('gitea-delete', function(e) {
|
|
||||||
var el = e.target;
|
|
||||||
var path = el.getAttribute('data-gitea-path');
|
|
||||||
if (!path) return;
|
|
||||||
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
|
||||||
var sha = el.getAttribute('data-gitea-sha') || '';
|
|
||||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
|
||||||
method: 'DELETE',
|
|
||||||
}).then(function(r) {
|
|
||||||
if (r.ok) self.refreshTree();
|
|
||||||
}).catch(function() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSubdir(path, el) {
|
|
||||||
var self = this;
|
|
||||||
// Check if already loaded
|
|
||||||
var ul = el.querySelector('ul');
|
|
||||||
if (ul) {
|
|
||||||
ul.style.display = ul.style.display === 'none' ? '' : 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
|
|
||||||
if (!r.ok) return;
|
|
||||||
var d = await r.json();
|
|
||||||
var children = d.tree || [];
|
|
||||||
ul = document.createElement('ul');
|
|
||||||
ul.className = 'sidebar-items';
|
|
||||||
ul.style.paddingLeft = '20px';
|
|
||||||
children.forEach(function(child) {
|
|
||||||
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
|
||||||
var li = document.createElement('li');
|
|
||||||
li.className = 'sidebar-item';
|
|
||||||
li.setAttribute('data-gitea-path', child.path);
|
|
||||||
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
|
||||||
li.setAttribute('data-gitea-sha', child.sha || '');
|
|
||||||
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
|
||||||
// Checkbox
|
|
||||||
var cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.className = 'gitea-checkbox';
|
|
||||||
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
|
||||||
cb.addEventListener('click', function(ev) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
self.selectItem(child.path, li, ev);
|
|
||||||
});
|
|
||||||
li.appendChild(cb);
|
|
||||||
// Icon
|
|
||||||
var iconSpan = document.createElement('span');
|
|
||||||
iconSpan.className = 'sidebar-icon';
|
|
||||||
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
|
|
||||||
li.appendChild(iconSpan);
|
|
||||||
// Name
|
|
||||||
var nameSpan = document.createElement('span');
|
|
||||||
nameSpan.textContent = child.name;
|
|
||||||
nameSpan.addEventListener('dblclick', function(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
self.startInlineRename(nameSpan, child.path, li);
|
|
||||||
});
|
|
||||||
li.appendChild(nameSpan);
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
el.appendChild(ul);
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async openFile(path, sha) {
|
|
||||||
this.filePath = path;
|
|
||||||
this.fileSha = sha || '';
|
|
||||||
this.showEditor = false;
|
|
||||||
this.showFile = true;
|
|
||||||
this.fileLoading = true;
|
|
||||||
this.fileLanguage = this.getLanguage(path);
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
|
||||||
if (r.ok) {
|
|
||||||
var d = await r.json();
|
|
||||||
this.fileContent = d.content || '';
|
|
||||||
this.fileSize = d.content ? d.content.length : 0;
|
|
||||||
} else {
|
|
||||||
this.fileContent = '[Error loading file]';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
this.fileContent = '[Error loading file]';
|
|
||||||
}
|
|
||||||
this.fileLoading = false;
|
|
||||||
// Highlight after Alpine renders
|
|
||||||
var self = this;
|
|
||||||
this.$nextTick(function() {
|
|
||||||
var block = self.$refs.codeBlock;
|
|
||||||
if (block && block.textContent && typeof Prism !== 'undefined') {
|
|
||||||
Prism.highlightElement(block);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getLanguage(path) {
|
|
||||||
var ext = (path || '').split('.').pop().toLowerCase();
|
|
||||||
var map = {
|
|
||||||
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
|
||||||
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
|
|
||||||
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
|
|
||||||
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
|
|
||||||
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
|
|
||||||
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
|
|
||||||
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
|
|
||||||
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
|
|
||||||
};
|
|
||||||
return map[ext] || 'text';
|
|
||||||
},
|
|
||||||
|
|
||||||
openEditor() {
|
|
||||||
this.editContent = this.fileContent;
|
|
||||||
this.showEditor = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveFile() {
|
|
||||||
if (!this.filePath) return;
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: this.filePath, content: this.editContent,
|
|
||||||
message: this.commitMessage, sha: this.fileSha,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
this.fileContent = this.editContent;
|
|
||||||
this.showEditor = false;
|
|
||||||
if (!this.commitHistory.includes(this.commitMessage)) {
|
|
||||||
this.commitHistory.unshift(this.commitMessage);
|
|
||||||
if (this.commitHistory.length > 10) this.commitHistory.pop();
|
|
||||||
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var d = await r.json();
|
|
||||||
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
|
||||||
}
|
|
||||||
} catch(e) { window.showToast('Network error', 'error'); }
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteCurrentFile() {
|
|
||||||
if (!this.filePath) return;
|
|
||||||
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
this.showFile = false;
|
|
||||||
this.filePath = '';
|
|
||||||
await this.refreshTree();
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshTree() {
|
|
||||||
// Reload main tree and sidebar tree without full page reload
|
|
||||||
this.loadMainTree(this.currentPath);
|
|
||||||
this.loadSidebarTree();
|
|
||||||
},
|
|
||||||
|
|
||||||
formatSize(bytes) {
|
|
||||||
if (!bytes) return '';
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
|
||||||
return (bytes/1048576).toFixed(1) + ' MB';
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Private Pages ──
|
|
||||||
|
|
||||||
async loadPrivatePages() {
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
|
||||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
newPrivate() {
|
|
||||||
this.editingPrivate = 'new';
|
|
||||||
this.editingPrivateTitle = '';
|
|
||||||
this.editingPrivateContent = '';
|
|
||||||
},
|
|
||||||
|
|
||||||
async openPrivate(id) {
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
|
||||||
if (r.ok) {
|
|
||||||
var d = await r.json();
|
|
||||||
this.editingPrivate = id;
|
|
||||||
this.editingPrivateTitle = d.page.title;
|
|
||||||
this.editingPrivateContent = d.page.content;
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async savePrivate() {
|
|
||||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
|
||||||
var method = 'POST';
|
|
||||||
if (this.editingPrivate !== 'new') {
|
|
||||||
url += '/' + this.editingPrivate;
|
|
||||||
method = 'PUT';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var r = await fetch(url, {
|
|
||||||
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
|
|
||||||
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
this.editingPrivate = null;
|
|
||||||
this.editingPrivateTitle = '';
|
|
||||||
this.editingPrivateContent = '';
|
|
||||||
await this.loadPrivatePages();
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Create new file
|
|
||||||
showNewFile: false,
|
|
||||||
newFilePath: '',
|
|
||||||
newFileContent: '',
|
|
||||||
newFileMsg: 'Create via FlowDeck',
|
|
||||||
async createNewFile() {
|
|
||||||
if (!this.newFilePath.trim()) return;
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
||||||
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
this.showNewFile = false;
|
|
||||||
this.newFilePath = '';
|
|
||||||
this.newFileContent = '';
|
|
||||||
this.newFileMsg = 'Create via FlowDeck';
|
|
||||||
await this.loadSidebarTree();
|
|
||||||
} else {
|
|
||||||
var d = await r.json();
|
|
||||||
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
|
|
||||||
}
|
|
||||||
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Upload files
|
|
||||||
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
|
|
||||||
async doUpload(ev) {
|
|
||||||
var files = ev.target.files;
|
|
||||||
if (!files.length) return;
|
|
||||||
for (var i = 0; i < files.length; i++) {
|
|
||||||
var form = new FormData();
|
|
||||||
form.append('file', files[i]);
|
|
||||||
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
|
|
||||||
} catch(e) { console.error('Upload error:', e); }
|
|
||||||
}
|
|
||||||
await this.loadSidebarTree();
|
|
||||||
ev.target.value = '';
|
|
||||||
},
|
|
||||||
|
|
||||||
async deletePrivate(id) {
|
|
||||||
if (!confirm('Delete this private page?')) return;
|
|
||||||
try {
|
|
||||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
|
||||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
|
||||||
});
|
|
||||||
if (r.ok) await this.loadPrivatePages();
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
|
|
||||||
getCsrfToken() {
|
|
||||||
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Multi-selection ──
|
|
||||||
multiSelect: false,
|
|
||||||
selectedPaths: [],
|
|
||||||
lastClickedPath: null,
|
|
||||||
|
|
||||||
toggleMultiSelect() {
|
|
||||||
this.multiSelect = !this.multiSelect;
|
|
||||||
var cbs = document.querySelectorAll('.gitea-checkbox');
|
|
||||||
var self = this;
|
|
||||||
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
|
|
||||||
if (!this.multiSelect) {
|
|
||||||
// Clear selection when leaving multi-select mode
|
|
||||||
cbs.forEach(function(cb) { cb.checked = false; });
|
|
||||||
this.selectedPaths = [];
|
|
||||||
this.lastClickedPath = null;
|
|
||||||
// Remove selected class
|
|
||||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
|
||||||
el.classList.remove('gitea-selected');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
selectItem(path, li, ev) {
|
|
||||||
if (ev.shiftKey && this.lastClickedPath) {
|
|
||||||
// Range select
|
|
||||||
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
|
|
||||||
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
|
|
||||||
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
|
|
||||||
if (startIdx >= 0 && endIdx >= 0) {
|
|
||||||
var lo = Math.min(startIdx, endIdx);
|
|
||||||
var hi = Math.max(startIdx, endIdx);
|
|
||||||
this.selectedPaths = [];
|
|
||||||
for (var i = lo; i <= hi; i++) {
|
|
||||||
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
|
|
||||||
all[i].classList.add('gitea-selected');
|
|
||||||
var cb = all[i].querySelector('.gitea-checkbox');
|
|
||||||
if (cb) cb.checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.multiSelect = true;
|
|
||||||
this.toggleMultiSelect(); // ensure checkboxes are visible
|
|
||||||
} else if (ev.ctrlKey || ev.metaKey) {
|
|
||||||
// Toggle single
|
|
||||||
var idx = this.selectedPaths.indexOf(path);
|
|
||||||
if (idx >= 0) {
|
|
||||||
this.selectedPaths.splice(idx, 1);
|
|
||||||
li.classList.remove('gitea-selected');
|
|
||||||
var cb = li.querySelector('.gitea-checkbox');
|
|
||||||
if (cb) cb.checked = false;
|
|
||||||
} else {
|
|
||||||
this.selectedPaths.push(path);
|
|
||||||
li.classList.add('gitea-selected');
|
|
||||||
var cb = li.querySelector('.gitea-checkbox');
|
|
||||||
if (cb) cb.checked = true;
|
|
||||||
}
|
|
||||||
this.multiSelect = this.selectedPaths.length > 0;
|
|
||||||
this.toggleMultiSelect();
|
|
||||||
} else {
|
|
||||||
// Normal select — clear multi-selection
|
|
||||||
this.selectedPaths = [path];
|
|
||||||
this.lastClickedPath = path;
|
|
||||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
|
||||||
el.classList.remove('gitea-selected');
|
|
||||||
});
|
|
||||||
li.classList.add('gitea-selected', 'active');
|
|
||||||
var cb = li.querySelector('.gitea-checkbox');
|
|
||||||
if (cb) cb.checked = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isSelected(path) {
|
|
||||||
return this.selectedPaths.indexOf(path) >= 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Inline rename ──
|
|
||||||
startInlineRename(nameSpan, path, li) {
|
|
||||||
var originalName = nameSpan.textContent;
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.value = originalName;
|
|
||||||
input.className = 'inline-rename-input';
|
|
||||||
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
|
|
||||||
nameSpan.replaceWith(input);
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
var self = this;
|
|
||||||
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
|
|
||||||
var cancel = function() {
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'page-name';
|
|
||||||
span.textContent = originalName;
|
|
||||||
input.replaceWith(span);
|
|
||||||
};
|
|
||||||
input.addEventListener('blur', finish);
|
|
||||||
input.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') finish();
|
|
||||||
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
finishInlineRename(input, originalName, path, li) {
|
|
||||||
if (input._renaming) return; // guard against double-fire
|
|
||||||
input._renaming = true;
|
|
||||||
var newName = input.value.trim();
|
|
||||||
if (!newName || newName === originalName) {
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'page-name';
|
|
||||||
span.textContent = originalName;
|
|
||||||
input.replaceWith(span);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var self = this;
|
|
||||||
// Construct new path by replacing the last segment
|
|
||||||
var parts = path.split('/');
|
|
||||||
parts.pop();
|
|
||||||
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
|
||||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
||||||
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
|
|
||||||
}).then(function(r) {
|
|
||||||
if (r.ok) {
|
|
||||||
self.refreshTree();
|
|
||||||
} else {
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'page-name';
|
|
||||||
span.textContent = originalName;
|
|
||||||
input.replaceWith(span);
|
|
||||||
window.showToast('Rename failed', 'error');
|
|
||||||
}
|
|
||||||
}).catch(function() {
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'page-name';
|
|
||||||
span.textContent = originalName;
|
|
||||||
input.replaceWith(span);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="gw-main" x-data="giteaWorkspace">
|
<div class="gw-main" x-data="giteaWorkspace">
|
||||||
<!-- File view -->
|
<!-- File view -->
|
||||||
<div x-show="showFile && !showEditor" x-transition>
|
<div x-show="showFile && !showEditor" x-transition>
|
||||||
|
|||||||
+1
-159
@@ -210,164 +210,6 @@ 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() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
|
||||||
function importWizard() {
|
|
||||||
return {
|
|
||||||
sources: [],
|
|
||||||
sourceId: '',
|
|
||||||
files: [],
|
|
||||||
dragOver: false,
|
|
||||||
mode: 'skip',
|
|
||||||
busy: false,
|
|
||||||
progress: 0,
|
|
||||||
error: '',
|
|
||||||
preview: null,
|
|
||||||
report: null,
|
|
||||||
mapping: {},
|
|
||||||
csrf: '',
|
|
||||||
urlValue: '',
|
|
||||||
urlError: '',
|
|
||||||
forgeProvider: 'gitea',
|
|
||||||
forgeOwner: '',
|
|
||||||
forgeRepo: '',
|
|
||||||
forgeState: 'all',
|
|
||||||
forgeError: '',
|
|
||||||
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
|
|
||||||
async init() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/import/sources');
|
|
||||||
this.sources = (await r.json()).sources || [];
|
|
||||||
} catch(e) {}
|
|
||||||
try {
|
|
||||||
const c = await fetch('/api/csrf-token');
|
|
||||||
this.csrf = (await c.json()).csrf_token;
|
|
||||||
} catch(e) {}
|
|
||||||
},
|
|
||||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
|
||||||
addFiles(list) {
|
|
||||||
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
|
|
||||||
this.preview = null; this.report = null; this.error = '';
|
|
||||||
},
|
|
||||||
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
|
|
||||||
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
|
|
||||||
buildForm(f) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', f.file);
|
|
||||||
if (this.sourceId) fd.append('source', this.sourceId);
|
|
||||||
fd.append('mode', this.mode);
|
|
||||||
return fd;
|
|
||||||
},
|
|
||||||
async doPreview() {
|
|
||||||
this.busy = true; this.error=''; this.report=null; this.progress=10;
|
|
||||||
try {
|
|
||||||
let merged = null;
|
|
||||||
for (let i=0;i<this.files.length;i++) {
|
|
||||||
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
|
|
||||||
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
|
|
||||||
merged.pages.push(...(d.pages||[]));
|
|
||||||
merged.warnings.push(...(d.warnings||[]));
|
|
||||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
|
||||||
}
|
|
||||||
this.preview = merged;
|
|
||||||
this.mapping = {};
|
|
||||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
|
||||||
} catch(e) { this.error = 'Erreur réseau'; }
|
|
||||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
|
||||||
},
|
|
||||||
async importOne(f) {
|
|
||||||
f.status = 'running';
|
|
||||||
const fd = this.buildForm(f);
|
|
||||||
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
|
|
||||||
const big = f.size > 5*1024*1024;
|
|
||||||
try {
|
|
||||||
if (big) {
|
|
||||||
fd.append('async','true');
|
|
||||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
|
||||||
return await this.pollJob(d.job_id, f);
|
|
||||||
}
|
|
||||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
|
||||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
|
||||||
return d;
|
|
||||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
|
||||||
},
|
|
||||||
async doImport() {
|
|
||||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
|
||||||
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
|
|
||||||
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
|
|
||||||
for (let i=0;i<this.files.length;i++) {
|
|
||||||
const d = await this.importOne(this.files[i]);
|
|
||||||
if (d) {
|
|
||||||
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
|
|
||||||
aggregate[k] += (d[k]||0);
|
|
||||||
aggregate.warnings.push(...(d.warnings||[]));
|
|
||||||
aggregate.errors.push(...(d.errors||[]));
|
|
||||||
aggregate.per_file.push({filename:this.files[i].name, report:d});
|
|
||||||
} else {
|
|
||||||
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
|
|
||||||
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
|
|
||||||
}
|
|
||||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
|
||||||
}
|
|
||||||
this.report = aggregate;
|
|
||||||
this.busy = false;
|
|
||||||
},
|
|
||||||
async doUrl() {
|
|
||||||
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/import/url', {
|
|
||||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
|
||||||
body: JSON.stringify({url:this.urlValue.trim()})
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
|
||||||
this.report = d; this.progress = 100;
|
|
||||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
|
||||||
finally { this.busy = false; }
|
|
||||||
},
|
|
||||||
async doForge(kind) {
|
|
||||||
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
|
|
||||||
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
|
|
||||||
try {
|
|
||||||
const r = await fetch(endpoint, {
|
|
||||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
|
|
||||||
repo:this.forgeRepo.trim(), state:this.forgeState
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
|
||||||
this.report = d; this.progress = 100;
|
|
||||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
|
||||||
finally { this.busy = false; }
|
|
||||||
},
|
|
||||||
async pollJob(jobId, f) {
|
|
||||||
for (let i=0;i<600;i++) {
|
|
||||||
await new Promise(res => setTimeout(res, 700));
|
|
||||||
const r = await fetch('/api/import/jobs/'+jobId);
|
|
||||||
const j = await r.json();
|
|
||||||
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
|
|
||||||
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
|
|
||||||
}
|
|
||||||
if (f) { f.status='error'; f.error='Délai dépassé'; }
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
downloadReport() {
|
|
||||||
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'flowdeck-import-report.json';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+1
-1040
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||||
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
|
|||||||
var self = this;
|
var self = this;
|
||||||
// Soft-delete all selected items
|
// Soft-delete all selected items
|
||||||
for (var i=0; i<ids.length; i++) {
|
for (var i=0; i<ids.length; i++) {
|
||||||
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||||
}
|
}
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
this._reloadAfterAction();
|
this._reloadAfterAction();
|
||||||
@@ -1207,7 +1207,7 @@ window._wsInitData = (function() {
|
|||||||
color = color || (store && store.newTagColor) || '#787774';
|
color = color || (store && store.newTagColor) || '#787774';
|
||||||
try {
|
try {
|
||||||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({name: tagName, color: color})
|
body: JSON.stringify({name: tagName, color: color})
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -1344,7 +1344,7 @@ window._wsInitData = (function() {
|
|||||||
if (!newName) return;
|
if (!newName) return;
|
||||||
try {
|
try {
|
||||||
var r = await fetch('/api/local-workspace/items/' + id, {
|
var r = await fetch('/api/local-workspace/items/' + id, {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({name: newName})
|
body: JSON.stringify({name: newName})
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -1524,7 +1524,7 @@ window._wsInitData = (function() {
|
|||||||
this.renamingId = null;
|
this.renamingId = null;
|
||||||
if (!n || n === node.name) return;
|
if (!n || n === node.name) return;
|
||||||
var r = await fetch('/api/local-workspace/items/' + node.id, {
|
var r = await fetch('/api/local-workspace/items/' + node.id, {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({name: n})
|
body: JSON.stringify({name: n})
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -1579,7 +1579,7 @@ window._wsInitData = (function() {
|
|||||||
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
|
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||||
this.clipboard.forEach(function(id) {
|
this.clipboard.forEach(function(id) {
|
||||||
fetch('/api/local-workspace/items/' + id + '/move', {
|
fetch('/api/local-workspace/items/' + id + '/move', {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({parent_id: targetId})
|
body: JSON.stringify({parent_id: targetId})
|
||||||
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
|
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
|
||||||
});
|
});
|
||||||
@@ -1590,7 +1590,7 @@ window._wsInitData = (function() {
|
|||||||
// ── Duplicate ──
|
// ── Duplicate ──
|
||||||
async duplicateItem(node) {
|
async duplicateItem(node) {
|
||||||
var r = await fetch('/api/local-workspace/items', {
|
var r = await fetch('/api/local-workspace/items', {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
|
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
|
||||||
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
|
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
|
||||||
});
|
});
|
||||||
@@ -1618,7 +1618,7 @@ window._wsInitData = (function() {
|
|||||||
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
|
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
|
||||||
this.undoVisible = true;
|
this.undoVisible = true;
|
||||||
// Delete via API
|
// Delete via API
|
||||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
|
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||||
if (!r.ok) { this.undoVisible = false; return; }
|
if (!r.ok) { this.undoVisible = false; return; }
|
||||||
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
|
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
|
||||||
this._reloadAfterAction();
|
this._reloadAfterAction();
|
||||||
@@ -1643,7 +1643,7 @@ window._wsInitData = (function() {
|
|||||||
self._reloadAfterAction();
|
self._reloadAfterAction();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
|
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
|
||||||
.then(function(r) { if (r.ok) restored++; })
|
.then(function(r) { if (r.ok) restored++; })
|
||||||
.finally(function() { restoreOne(i + 1); });
|
.finally(function() { restoreOne(i + 1); });
|
||||||
}
|
}
|
||||||
@@ -1941,7 +1941,7 @@ window._wsInitData = (function() {
|
|||||||
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
||||||
var r = await fetch('/api/local-workspace/items', {
|
var r = await fetch('/api/local-workspace/items', {
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body:JSON.stringify(body)
|
body:JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -2025,7 +2025,7 @@ window._wsInitData = (function() {
|
|||||||
if (!n||!this.target) return;
|
if (!n||!this.target) return;
|
||||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||||||
method:'PUT',
|
method:'PUT',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({name:n})
|
body:JSON.stringify({name:n})
|
||||||
});
|
});
|
||||||
if (r.ok) { this._reloadAfterAction(); }
|
if (r.ok) { this._reloadAfterAction(); }
|
||||||
@@ -2038,7 +2038,7 @@ window._wsInitData = (function() {
|
|||||||
|
|
||||||
async doDelete() {
|
async doDelete() {
|
||||||
if (!this.target) return;
|
if (!this.target) return;
|
||||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||||
if (r.ok) { this._reloadAfterAction(); }
|
if (r.ok) { this._reloadAfterAction(); }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2231,7 +2231,7 @@ window._wsInitData = (function() {
|
|||||||
try {
|
try {
|
||||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -2327,7 +2327,7 @@ window._wsInitData = (function() {
|
|||||||
try {
|
try {
|
||||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
|
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: tagName})
|
body: JSON.stringify({name: tagName})
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -2346,7 +2346,7 @@ window._wsInitData = (function() {
|
|||||||
|
|
||||||
async removeTag(itemId, tagId) {
|
async removeTag(itemId, tagId) {
|
||||||
try {
|
try {
|
||||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
|
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -2492,7 +2492,7 @@ window._wsInitData = (function() {
|
|||||||
try {
|
try {
|
||||||
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
|
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ parent_id: parentId || null })
|
body: JSON.stringify({ parent_id: parentId || null })
|
||||||
});
|
});
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
@@ -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) {
|
||||||
|
|||||||
+13
-13
@@ -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',
|
||||||
@@ -1337,7 +1337,7 @@ function settingsInit() {
|
|||||||
var n = this.newTagName.trim();
|
var n = this.newTagName.trim();
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
var r = await fetch('/api/settings/tags', {
|
var r = await fetch('/api/settings/tags', {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({name: n, color: this.newTagColor})
|
body: JSON.stringify({name: n, color: this.newTagColor})
|
||||||
});
|
});
|
||||||
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
||||||
@@ -1345,7 +1345,7 @@ function settingsInit() {
|
|||||||
|
|
||||||
async updateTagColor(id, color) {
|
async updateTagColor(id, color) {
|
||||||
await fetch('/api/settings/tags/' + id, {
|
await fetch('/api/settings/tags/' + id, {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({color: color})
|
body: JSON.stringify({color: color})
|
||||||
});
|
});
|
||||||
await this.loadTags();
|
await this.loadTags();
|
||||||
@@ -1353,7 +1353,7 @@ function settingsInit() {
|
|||||||
|
|
||||||
async deleteTag(id) {
|
async deleteTag(id) {
|
||||||
if (!confirm('Delete this tag?')) return;
|
if (!confirm('Delete this tag?')) return;
|
||||||
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||||
await this.loadTags();
|
await this.loadTags();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1375,7 +1375,7 @@ function settingsInit() {
|
|||||||
this.renamingTag = null; return;
|
this.renamingTag = null; return;
|
||||||
}
|
}
|
||||||
await fetch('/api/settings/tags/' + tag.id, {
|
await fetch('/api/settings/tags/' + tag.id, {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||||
body: JSON.stringify({name: newName})
|
body: JSON.stringify({name: newName})
|
||||||
});
|
});
|
||||||
this.renamingTag = null;
|
this.renamingTag = null;
|
||||||
@@ -1648,7 +1648,7 @@ function settingsInit() {
|
|||||||
try {
|
try {
|
||||||
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
@@ -1677,7 +1677,7 @@ function settingsInit() {
|
|||||||
if (f.models && f.models.length) body.models = f.models;
|
if (f.models && f.models.length) body.models = f.models;
|
||||||
var r = await fetch('/api/agent/keys/' + id, {
|
var r = await fetch('/api/agent/keys/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
@@ -1713,7 +1713,7 @@ function settingsInit() {
|
|||||||
if (f.api_key) body.api_key = f.api_key;
|
if (f.api_key) body.api_key = f.api_key;
|
||||||
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
@@ -1739,7 +1739,7 @@ function settingsInit() {
|
|||||||
var f = this.keyForm(id);
|
var f = this.keyForm(id);
|
||||||
f.deleting = true; f.msg = ''; f.ok = false;
|
f.deleting = true; f.msg = ''; f.ok = false;
|
||||||
try {
|
try {
|
||||||
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
|
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
||||||
@@ -1899,7 +1899,7 @@ function settingsInit() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
var form = new FormData();
|
var form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
|
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||||
@@ -1910,7 +1910,7 @@ function settingsInit() {
|
|||||||
async selectAvatarColor(color) {
|
async selectAvatarColor(color) {
|
||||||
this.avatarColor = color;
|
this.avatarColor = color;
|
||||||
var r = await fetch('/api/settings/avatar-color', {
|
var r = await fetch('/api/settings/avatar-color', {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({color: color})
|
body: JSON.stringify({color: color})
|
||||||
});
|
});
|
||||||
if (r.ok) { this.avatarUrl = ''; }
|
if (r.ok) { this.avatarUrl = ''; }
|
||||||
@@ -2055,7 +2055,7 @@ function settingsInit() {
|
|||||||
// ── v5.2.0 API tokens ──
|
// ── v5.2.0 API tokens ──
|
||||||
async loadApiTokens() {
|
async loadApiTokens() {
|
||||||
try {
|
try {
|
||||||
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
|
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
this.apiTokens = d.tokens || [];
|
this.apiTokens = d.tokens || [];
|
||||||
} catch(e) { this.apiTokens = []; }
|
} catch(e) { this.apiTokens = []; }
|
||||||
@@ -2065,7 +2065,7 @@ function settingsInit() {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
|
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
|
||||||
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
|
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
|
||||||
this.newToken = d;
|
this.newToken = d;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user