diff --git a/CHANGELOG.md b/CHANGELOG.md index 102e485..21fce45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog - FlowDeck +## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers) + +### Changed + +- **A28 lot 2** : `app/routers/dashboard.py` (**2 735 lignes, 63 routes**) + devient le package `app/routers/dashboard/` : + · `local_workspace` 559 L (15 routes), `pages_html` 485 (6), + `account_settings` 439 (16), `workspace` 321 (9), `pages_api` 240 (6), + `workspaces` 131 (6), `public` 78 (1), `account_api` 77 (4) + · `_common.py` (774 L) : les **15 helpers top-level intercalés** dans + l'ancien fichier + état (`logger`, `_VERSION`, `WORKSPACE_COOKIE`) + · `__init__.py` : re-export complet (les 7 importateurs existants — + main, board ×3, my_tasks, web_clipper, wiki, sites, tests — n'ont + rien changé) + `__all__` +- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique + byte-à-byte** (ordre d'enregistrement des routes préservé) + +### Fixed (pièges de la découpe, rattrapés) + +- segment décorateur sans sa fonction (`def`) → corps perdus en silence : + assert `def in seg` ajoutée + récupération depuis git +- collision `settings` : la section `settings.py` entrait en conflit avec + `from app.config import settings` du header (`hasattr` du fromlist de + Python → la section était ignorée) → renommée `account_settings` +- `WORKSPACE_COOKIE` utilisé dans `workspaces.py` sans import (F821) → + import `._common` ajouté +- script `__all__` tronquant la fin du fichier → `__init__.py` réécrit + +### Notes + +- Reste A28 : `collections.py` 2 622 L, `board.py` 2 101 L +- Suite complète : **1091/1091** · ruff OK + ## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 235ef63..c5e9393 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1148,7 +1148,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.* - [x] **A27 — 13 900 lignes de JS inline — TERMINÉ 2026-10-01 (extraction 11 874 L / -85 % + eslint 0/0) — reste : structurel seul (base 1 523 L)**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint.* — **phase 1 faite 2026-10-01** : **7 templates sans Jinja → 9 fichiers `static/js/*.js` (4 243 lignes, -30 % du JS inline)** : agent_panel_1/2 (4 243… dont le 1 788 L livré sur CHAQUE page), library 1039, gitea_workspace 626, _icon_picker_1/2, _ctx_menu, import, workspaces — **un fichier par bloc** (ordre/timing identique, pas de defer, attributs conservés, `?v={{ asset_version }}`), `node --check` vert sur les 9, 0 script inline restant dans les cibles, suite 1089/1089. **Lint** : eslint **installé globalement** (`npm i -g eslint`, la config était déjà flat v9 sans dépendances) → `eslint static/js` = **0 erreur / 120 warnings** (no-unused-vars 69, no-empty 36, no-undef 15 — baseline à nettoyer opportunistiquement). **Phase 2a faite 2026-10-01 (l'éditeur, le plus gros bloc interpolé)** : `_page_editor_scripts` **2 516 L extraites** vers `static/js/page_editor_scripts.js` — recette « config JSON » : les 8 interpolations Jinja lisent `PD` = `JSON.parse(#page-data)` (le bloc JSON **existait déjà** juste avant le script) ; la route enrichit `page_data` de `updated_at`, `created_at`, `user_id`, `is_shared` (**dérivé hoisté : une seule expression pour le ctx ET le JSON**) et `clip_icon` (macro `fd_icon` rendue côté serveur) ; `workspace_key` reste vide comme avant (jamais défini dans ce ctx — parité). **Cumul A27 : 6 759 L extraites** (13 904 → 7 145 inline). **8 tests adaptés** (ils lisaient le template source → lisent maintenant `static/js/page_editor_scripts.js` ; `pageIsShared:true` → parsing du JSON `#page-data` → `is_shared is True`). **Phase 2b faite 2026-10-01** : **+3 801 L** extraites avec la même recette config JSON : `local_workspace` (2031 — `lw-config` : current_folder_id/workspace_id), `settings` (1093 — `st-config` : avatar/user/is_admin/auth_method, **2 routes rendent ce template**, expressions `or ""` préservées pour Undefined), `_page_editor_realtime` (531 — `rt-config` : SELF id/login/full_name/color), `board` (146 — `bd-config` : owner/repo/initial_view). BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON) au lieu d'être interpolées dans des strings JS. **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**), `node --check` vert ×4, suite 1089/1089, eslint **0 erreur / 279 warnings** (12 fichiers). **Phase 2c faite 2026-10-01 (BILAN : extraction TERMINÉE)** : `database_table` **1 314 L extraites** — le Jinja du bloc était confiné à la construction de l'objet de config (4 clés + `{% if collection_data %}`) → config JSON `null`-vs-objet (`#db-config`), le JS appelle `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)` : les 2 branches Jinja disparaissent ; 2 tests adaptés (source → `database_table.js`) + `FlowDeckDB` dans src. **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 + 1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`, `node --check` vert partout, suite 1089/1089. **Lint A27 TERMINÉ 2026-10-01 : `eslint static/js` = 0 erreur / 0 warning** (285 → 0, 22 fichiers). 3 familles traitées : (1) **no-empty ×70** = tous des `catch (x) {}` vides → `catch { /* volontaire */ }` (binding optionnel ES2019 + commentaire : passe no-empty ET no-unused-vars, zéro changement de comportement) ; (2) **no-unused-vars ×171** = bindings de catch retirés + 24 lignes mortes déterministes (suppressions avec assert sur le texte exact : `var self = this` ×8, compteurs jamais lus `restored++`/`resolved++`/`acc`/`today`, `uid()`/`propName()` sans 1 appel, etc.) + `/* exported */` sur les **10 fonctions appelées depuis les attributs HTML** (vérifiées par grep, 1 template chacune) ; (3) **no-undef ×44** = globaux réels déclarés dans `eslint.config.mjs` (`getSvgIcon` = script inline de `base.html`, `TextDecoder` = API navigateur, `Prism` = CDN) + **2 vrais correctifs** : `settings.js` utilisait `typeof toast === 'function'` (guard toujours faux → les toasts timezone/SAML ne s'affichaient JAMAIS) → `window.showToast`, et `_wsInitData = window._wsInitData` (auto-affectation sans effet, global implicite) supprimé. **Reste A27** : `base` 1 523 L structurel (inline par nature, décision assumée) + ~500 L de petits blocs hors cibles. Effort : **L** (fait). -- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Reste A28** : `dashboard.py` 2 735 L, `collections.py` 2 622 L, `board.py` 2 101 L (même recette) → suite 1091/1091, version 7.29.0. +- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Reste A28** : `collections.py` 2 622 L, `board.py` 2 101 L → suite 1091/1091, version 7.30.0. - [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**. - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* - [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**. @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 — reste : dashboard/collections/board. +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 — reste : collections/board. diff --git a/VERSION b/VERSION index d88eca0..ea5c418 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.29.0 +7.30.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index e188dfd..d4398c9 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.30.0 (audit — A28 lot 2 : dashboard.py 2 735 L → package 10 fichiers) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index 10972e9..7478594 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.29.0", + version="7.30.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py deleted file mode 100644 index ca7c768..0000000 --- a/app/routers/dashboard.py +++ /dev/null @@ -1,2735 +0,0 @@ -"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data.""" -from __future__ import annotations - -import logging -from datetime import UTC - -from fastapi import APIRouter, Body, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse - -from app.auth.session import SessionManager -from app.config import settings -from app.db import get_conn -from app.services.gitea_client import get_user_gitea_client, gitea - -logger = logging.getLogger(__name__) -router = APIRouter(tags=["dashboard"]) - -_VERSION = None - - -def _get_app_version() -> str: - """Read version from VERSION file with caching.""" - global _VERSION - if _VERSION is not None: - return _VERSION - try: - import os - version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION") - if os.path.exists(version_path): - with open(version_path) as f: - _VERSION = f.read().strip() - else: - # Docker fallback - version_path = "/app/VERSION" - if os.path.exists(version_path): - with open(version_path) as f: - _VERSION = f.read().strip() - else: - _VERSION = "0.0.0" - except Exception: - _VERSION = "0.0.0" - return _VERSION - - -def _get_user_or_redirect(request: Request): - """Return decoded user or a RedirectResponse to login page. - Skips redirect when DB has no users (fresh install / test env).""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - # Allow through if no users exist yet (fresh install / tests) - try: - with get_conn() as conn: - count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - if count == 0: - return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} - except Exception: - logger.exception("_get_user_or_redirect") - return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) - return user - - -def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: - """Return list of local workspaces for a user.""" - if not user: - return [] - try: - with get_conn() as conn: - rows = conn.execute( - "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", - (user["id"],) - ).fetchall() - return [{"id": r["id"], "name": r["name"]} for r in rows] - except Exception: - return [] - - -def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict: - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - ws = user.get("login", "Bruno") if user else "Bruno" - initial = ws[0].upper() if ws else "B" - - # Get avatar info from DB - avatar_url = "" - avatar_color = "#3A3A3A" - if user: - try: - with get_conn() as conn: - row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone() - if row: - avatar_url = row["avatar_url"] or "" - avatar_color = row["avatar_color"] or "#3A3A3A" - except Exception: - logger.exception("_sidebar_data") - - recent_pages = [] - for repo in repos[:10]: - full_name = repo.get("full_name", "") - recent_pages.append({ - "id": full_name, - "name": repo.get("name", full_name), - "icon": "folder", - "url": f"/board/{full_name}", - "active": False, - "indent": 0, - "depth": 0, - "has_children": False, - "children": [], - }) - - # Active workspace from cookie (skip on pages like /workspaces where no - # workspace context should be shown) - from app.routers.board import _load_workspace_pages - ws_cookie = request.cookies.get("flowdeck_workspace", "") - active_ws_name = "Workspace" - workspace_pages = [] - gitea_workspace = False - gitea_owner = "" - gitea_repo = "" - has_active_workspace = False - local_ws_id = 0 - - if ws_cookie and ws_cookie.startswith("gitea:"): - # Gitea workspace: set owner/repo for client-side tree loading - # AND open the local workspace mirror of the same name in the - # sidebar's top "My Workspaces" section, in parallel with the - # Gitea repository tree. - parts = ws_cookie.split(":", 2) - if len(parts) >= 3: - gitea_owner = parts[1] - gitea_repo = parts[2] - active_ws_name = f"{gitea_owner}/{gitea_repo}" - gitea_workspace = True - has_active_workspace = True - # Load the local mirror workspace tree so it appears in "My - # Workspaces" alongside the Gitea repository section. - if user: - try: - with get_conn() as conn: - row = conn.execute( - "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", - (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") - ).fetchone() - if row: - local_ws_id = row["id"] - workspace_pages = _load_workspace_pages(str(local_ws_id)) - except (ValueError, Exception): - pass - elif include_workspace and ws_cookie and user: - try: - wsi = int(ws_cookie) - with get_conn() as conn: - # Verify this workspace belongs to the current user - row = conn.execute( - "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", - (wsi, user["id"]) - ).fetchone() - if row: - active_ws_name = row["name"] - workspace_pages = _load_workspace_pages(ws_cookie) - has_active_workspace = True - # else: stale cookie from another user — ignore - except (ValueError, Exception): - pass - - # Auth method & OAuth badge data - auth_method = "local" - gitea_linked = False - github_linked = False - if user and user.get("id"): - try: - with get_conn() as conn: - am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() - if am_row and am_row["auth_method"]: - auth_method = am_row["auth_method"] - tokens = conn.execute( - "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) - ).fetchall() - for t in tokens: - if t["provider"] == "gitea": - gitea_linked = True - elif t["provider"] == "github": - github_linked = True - except Exception: - logger.exception("_sidebar_data") - - # Get local workspace ID for Gitea workspace mirror - local_ws_id = 0 - if gitea_workspace and gitea_owner and gitea_repo: - try: - with get_conn() as conn: - row = conn.execute( - "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", - (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") - ).fetchone() - if row: - local_ws_id = row["id"] - except Exception: - logger.exception("_sidebar_data") - - # Private pages for mirror workspace (when Gitea remote active) - private_pages = [] - if gitea_workspace and local_ws_id: - try: - with get_conn() as conn: - pp_rows = conn.execute( - "SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20", - (local_ws_id,) - ).fetchall() - private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] - except Exception: - logger.exception("_sidebar_data") - - # Shared / received / published pages for the sidebar (Par moi / Avec moi) - shared_made_pages = [] - shared_received_pages = [] - published_pages = [] - shared_pages = [] - if user and user.get("id"): - from app.routers.board import _load_shared_sidebar_pages - shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"]) - - sidebar = { - "workspace_name": ws, "workspace_initial": initial, - "active_ws_name": active_ws_name, - "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", - "gitea_workspace": gitea_workspace, - "gitea_owner": gitea_owner, - "gitea_repo": gitea_repo, - "local_ws_id": local_ws_id, - "workspace_pages": workspace_pages, - "current_page": "Dashboard", "last_edited": "now", - "recent_pages": recent_pages, - "private_pages": private_pages, - "favorite_pages": [], - "shared_pages": shared_pages, - "shared_made_pages": shared_made_pages, - "shared_received_pages": shared_received_pages, - "published_pages": published_pages, - "user": user, - "avatar_url": avatar_url, - "avatar_color": avatar_color, - "auth_method": auth_method, - "gitea_linked": gitea_linked, - "github_linked": github_linked, - "has_active_workspace": has_active_workspace, - "app_version": _get_app_version(), - } - - sidebar["local_workspaces"] = _local_workspaces_for_user(user) - - return sidebar - - -@router.get("/trash", response_class=HTMLResponse) -def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): - """Trash page — scoped to workspace if owner/repo provided.""" - from app.routers.board import _sidebar_data as board_sidebar - from app.templating import ENV - env = ENV - sidebar = board_sidebar(request, owner, repo) - with get_conn() as conn: - ws_key = f"{owner}/{repo}" if owner and repo else "" - # Pages are soft-deleted via parent_section='Trash' - if ws_key: - rows = conn.execute( - "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", - (ws_key,), - ).fetchall() - else: - rows = conn.execute( - "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", - ).fetchall() - sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] - # Pass active workspace for breadcrumb nav menu - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - sidebar["nav_workspace_id"] = ws["id"] if ws else 0 - template = env.get_template("trash.html") - return template.render(**sidebar) - - -@router.get("/library", response_class=HTMLResponse) -def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): - """Library page — tabbed view (recents, favorites, shared, published, private, workspace). - - Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. - """ - from app.routers.board import _sidebar_data as board_sidebar - from app.templating import ENV - env = ENV - sidebar = board_sidebar(request, owner, repo) - # Pass active workspace for breadcrumb nav menu - ws_key_ws = f"{owner}/{repo}" if owner and repo else "" - if ws_key_ws: - with get_conn() as conn: - ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone() - sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0 - else: - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - sidebar["nav_workspace_id"] = ws["id"] if ws else 0 - - template = env.get_template("library.html") - sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0) - # Gitea workspace context for Repository tab - sidebar["is_gitea_workspace"] = bool(owner and repo) - sidebar["gitea_workspace_owner"] = owner - sidebar["gitea_workspace_repo"] = repo - return template.render(**sidebar) - - -@router.get("/pages/{page_id}", response_class=HTMLResponse) -def view_page_root(request: Request, page_id: int): - """Render a Markdown page at root level with workspace context — or file viewer. - ?embed=1 — minimal mode for side peek (editor only, no header).""" - embed = request.query_params.get("embed") == "1" - from app.routers.board import _sidebar_data as board_sidebar - from app.templating import ENV - env = ENV - with get_conn() as conn: - row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - return RedirectResponse("/workspaces", status_code=302) - page = dict(row) - # v6.5.0: synced blocks resolve server-side at read time. - from app.services.synced_blocks import resolve_content_json - page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) - - ws = page.get("workspace", "") - parts = ws.split("/") if "/" in ws else ["", ""] - owner, repo = parts[0], parts[1] if len(parts) > 1 else "" - sidebar = board_sidebar(request, owner, repo) - # Load sub-pages - with get_conn() as conn: - subs = conn.execute( - "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", - (page_id,), - ).fetchall() - fav = conn.execute( - "SELECT id FROM favorites WHERE user_id=? AND page_id=?", - (1, page_id), - ).fetchone() - - # Build page_data, including file metadata for uploaded files - _locked = bool(page.get("is_locked", 0)) - _locked_by = page.get("locked_by") if "locked_by" in page else None - _sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - _uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None - _can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid) - page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), - "is_locked": _locked, - "locked_by": _locked_by, - "can_edit": _can_edit, - "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, - "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} - - # For file pages, extract file metadata and add to page_data - if page.get("content_format") == "file": - import json as _json - try: - meta = _json.loads(page.get("content", "{}")) - except _json.JSONDecodeError: - meta = {} - file_path = meta.get("file_path", "").replace("\\", "/") - mime_type = meta.get("mime_type", "application/octet-stream") - file_size = meta.get("size", 0) - fp_parts = file_path.split("/") - ws_id = "" - for p in fp_parts: - if p.startswith("workspace_"): - ws_id = p.replace("workspace_", "") - break - filename = fp_parts[-1] if fp_parts else page.get("title", "File") - from urllib.parse import quote - safe_name = quote(filename, safe='') - file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else "" - page_data["file_url"] = file_url - page_data["file_mime"] = mime_type - page_data["file_size"] = file_size - page_data["file_name"] = filename - - # For collection (database) pages, load collection + properties + pages - collection_data = None - if page.get("content_format") == "collection" and page.get("collection_id"): - with get_conn() as conn: - col = conn.execute( - "SELECT * FROM collections WHERE id=?", (page["collection_id"],) - ).fetchone() - if col: - props = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", - (page["collection_id"],), - ).fetchall() - ] - cpages = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", - (page["collection_id"],), - ).fetchall() - ] - cviews = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", - (page["collection_id"],), - ).fetchall() - ] - collection_data = { - "collection": dict(col), - "properties": props, - "pages": cpages, - "views": cviews, - } - page_data["collection_id"] = page["collection_id"] - - with get_conn() as conn: - nav_crumbs = _nav_breadcrumb(conn, page_id) - # dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27) - page_is_shared = ( - bool(page.get("is_shared", 0)) - or page.get("share_mode", "private") != "private" - or bool(page.get("published", 0)) - ) - ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], - "page_favorited": fav is not None, - "page_share_mode": page.get("share_mode", "private"), - "page_published": bool(page.get("published", 0)), - "page_is_shared": page_is_shared, - "page_data": page_data, - "collection_data": collection_data, - "breadcrumb_items": nav_crumbs, - "nav_workspace_id": page.get("workspace_id") or 0, - "nav_page_id": page_id, - "embed_mode": embed} - # A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON) - # au lieu des interpolations Jinja — une seule source, même calculs que le - # ctx ci-dessus. - from app.templating import ENV as _ENV27 - page_data.update( - updated_at=page.get("updated_at", ""), - created_at=page.get("created_at", ""), - user_id=_uid or 0, - is_shared=page_is_shared, - clip_icon=_ENV27.from_string( - "{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}" - ).render(), - ) - # Select template: collection pages use database table view - if page.get("content_format") == "collection" and not embed: - template = env.get_template("page_editor_collection.html") - else: - template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") - response = template.render(**ctx) - return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) - - -@router.get("/accounts", response_class=HTMLResponse) -def accounts_page(request: Request): - """Account management panel.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - with get_conn() as conn: - users = conn.execute( - "SELECT id, login, full_name, email, avatar_url, avatar_color, " - "is_admin, is_active, created_at FROM users ORDER BY created_at DESC" - ).fetchall() - ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} - template = env.get_template("accounts.html") - return template.render(**ctx) - - -@router.get("/help", response_class=HTMLResponse) -def help_page(request: Request): - """Comprehensive help & documentation page.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - # Render via a block-based template so content_html lands in {% block content %} - block_tpl = env.from_string( - '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' - ) - return HTMLResponse(block_tpl.render( - **sidebar, - request=request, - page_title="Help", - title_prefix="Help", - page_icon="❓", - content_html=""" -
-
-

❓ FlowDeck Help

-

Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.

-
- -
-
-

🚀Getting Started

-

FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.

-
    -
  • Create a workspace from the Workspaces page
  • -
  • Click 📄 New Page in the sidebar to start writing
  • -
  • Use Ctrl+N anywhere to create a page
  • -
-
- -
-

📝Pages & Editor

-

Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.

-
    -
  • Type / for the slash command menu
  • -
  • Drag & drop pages in the sidebar to reorganize
  • -
  • Right-click for context menu (duplicate, rename, delete)
  • -
-
- -
-

{{ fd_icon("folder",16) }}Workspaces

-

Organize your work into separate workspaces. Each has its own pages and files.

-
    -
  • Local Files stored on your server
  • -
  • Gitea Connect to browse & edit repos
  • -
  • GitHub Connect via Settings → Integrations
  • -
-
- -
-

🦎Gitea Integration

-

Connect your Gitea account to access repositories directly from FlowDeck.

-
    -
  • Go to Settings → Integrations to connect
  • -
  • Browse repo file trees in the sidebar
  • -
  • Create & edit files with commit messages
  • -
  • Sync labels as tags
  • -
-
- -
-

🌐Sharing & Publishing

-

Share pages with collaborators or publish them to the web.

-
    -
  • Click Share in the page editor top-right
  • -
  • Share with specific users or get a public link
  • -
  • Publish to make a page visible at /p/your-slug
  • -
-
- -
-

📚Library, Trash & Tasks

-

Find all your content in one place with powerful filtering.

-
    -
  • Library — Tabs for Recents, Favorites, Shared, Published
  • -
  • Trash — Soft-deleted pages (30-day retention)
  • -
  • My Tasks — Aggregated tasks from all collections
  • -
-
- -
-

📶Offline & PWA

-

Install FlowDeck as an app and keep working without a connection.

-
    -
  • Install — browser menu → Install app / Add to Home Screen
  • -
  • Edits made offline are queued locally and synced automatically
  • -
  • A ⟳ marker shows pages with pending changes
  • -
-
-
- -
-

⌨️ Keyboard Shortcuts

-
Ctrl+N
Create new page
-
Ctrl+K
Quick find / command palette
-
Ctrl+S
Save current page
-
F2
Rename selected item
-
Delete
Move selected item to trash
-
Escape
Close modal / cancel editing
-
Enter
Open selected page
-
Ctrl+O
New AI chat (in footer)
-
- -
-

🔐 Authentication

-

-FlowDeck supports three authentication methods:
-Local Email + password — create an account on the login page.
-Gitea OAuth Login with your Gitea account. Your repos appear as workspaces.
-GitHub OAuth Login or link your GitHub account in Settings → Integrations.

-Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user. -

-

-SSO Enterprise SSO (v6.7.0) — sign in with your organization account.
-For administrators: open Settings → Admin → SSO / Enterprise and pick a provider:
-• SAML 2.0 — paste the IdP Entity ID, SSO URL and signing certificate, then give the IdP this -/auth/saml/metadata link (it contains the SP Entity ID, ACS URL and certificate).
-• OpenID Connect — paste the Issuer URL, Client ID and Client Secret (PKCE is used, scopes default to openid profile email).
-• Provisioning — accounts are created automatically on first login, groups from the IdP map to workspace roles, -and SSO only disables local login (admins keep their local door). Every attempt is audited in -Settings → Admin → SSO / Enterprise (login history). -

-
- -
-

📶 Offline mode (PWA)

-

-FlowDeck is a Progressive Web App: pages you visited stay available offline and your -edits are saved locally, then synchronised when the connection returns.

-Install: open your browser menu and choose Install app (Chrome/Edge) or -Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
-Offline editing: while offline, the editor stores changes in the browser -(IndexedDB) and shows an offline banner with the number of pending changes. A -⟳ icon appears next to pages that have unsynced edits.
-Reconnection: the queue is replayed automatically (and via Background Sync). -A spinner badge appears while syncing, followed by a confirmation toast.
-Conflicts: if a page changed on the server, the latest edit wins and a notice is -shown. If a page was deleted server-side, your offline copy is recreated as an orphan -page. If a page with the same title already exists, the offline copy is renamed -“Title (copie offline)”. -

-
- -
-

🔌 API publique v2

-

-FlowDeck exposes a full REST API under /api/v2 for third-party integrations.
-Auth: create a token in Settings → API tokens, then send it as -Authorization: Bearer <token>. Tokens carry scopes -read, write or admin (a higher scope implies the lower ones).
-Features: CRUD on collections, pages, properties, views, comments, notifications, -favorites, tags, sharing, sprints and templates; pagination (?limit=&offset= + -X-Total-Count), filters (filter[prop]=value), sorting, full-text search -(/api/v2/search), idempotency (Idempotency-Key) and RFC 7807 error bodies.
-Reference: interactive OpenAPI docs at /docs -(also /redoc, docs/openapi-v2.json). -

-
- -
-

💡 Tips

-

-• Toggle the sidebar with the « button in the top-left corner.
-• Switch between workspaces using the dropdown menu in the sidebar header.
-• The Private section appears when a remote workspace is active — files here stay local.
-• Hover over any sidebar item to see action buttons (favorite, share, delete).
-• Use Ctrl+Click or Shift+Click to multi-select items in the sidebar. -

-
- -
""" - )) - - -@router.get("/accounts/settings", response_class=HTMLResponse) -def settings_page(request: Request): - """User settings page — profile, forges, tokens.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - from fastapi.responses import RedirectResponse - return RedirectResponse("/auth/login?provider=local", status_code=302) - # Check forge connections - gitea_connected = False - github_connected = False - if user.get("id"): - with get_conn() as conn: - tokens = conn.execute( - "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) - ).fetchall() - for t in tokens: - if t["provider"] == "gitea": - gitea_connected = True - elif t["provider"] == "github": - github_connected = True - ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected} - template = env.get_template("settings.html") - response = template.render(**ctx) - return HTMLResponse(content=response) - - -# ═══════════ User API endpoints ═══════════ - -def _get_user_id(request: Request) -> int: - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - return user["id"] if user and user.get("id") else 1 - - -def _require_user_id(request: Request) -> int: - """A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent - plus le fallback « legacy single-user » → id 1 = l'admin seedé).""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(401, "Authentication required") - return user["id"] - - -@router.put("/api/user/profile") -def update_profile(request: Request, body: dict = Body(default={})): - full_name = body.get("full_name", "").strip() - uid = _require_user_id(request) - with get_conn() as conn: - conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) - conn.commit() - return {"status": "ok"} - - -@router.put("/api/user/password") -def update_password(request: Request, body: dict = Body(default={})): - from app.password_utils import hash_password, verify_password - password = body.get("password", "").strip() - if len(password) < 6: - return {"error": "Password must be at least 6 characters"} - uid = _require_user_id(request) - # A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp). - current = body.get("current_password", "") - with get_conn() as conn: - row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone() - if not row or not verify_password(current, row["password_hash"]): - raise HTTPException(403, "Current password is incorrect") - conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/user/token") -def generate_token(request: Request): - import secrets - uid = _require_user_id(request) - token = secrets.token_hex(32) - with get_conn() as conn: - conn.execute( - "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - (uid, token), - ) - conn.commit() - return {"token": f"fd_{token}"} - - -@router.delete("/api/user/forge/{provider}") -def disconnect_forge(request: Request, provider: str): - uid = _require_user_id(request) - with get_conn() as conn: - conn.execute( - "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) - ) - conn.commit() - return {"status": "ok"} - - -@router.get("/", response_class=HTMLResponse) -async def dashboard( - request: Request, - search: str = Query(default=""), - show_archived: bool = Query(default=False), -): - """Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - - # ── Not authenticated → show landing page ── - if not user: - # Allow through if DB is empty (fresh install) - try: - with get_conn() as conn: - count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - if count == 0: - from app.templating import ENV - env = ENV - template = env.get_template("landing.html") - return template.render() - except Exception: - logger.exception("dashboard") - from app.templating import ENV - env = ENV - template = env.get_template("landing.html") - return template.render() - - # ── Authenticated ── - user_id = user.get("id", 1) - has_gitea = False - try: - with get_conn() as conn: - tok = conn.execute( - "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", - (user_id,), - ).fetchone() - has_gitea = bool(tok) - except Exception: - logger.exception("dashboard") - - if not has_gitea: - # Check if user has any workspace - try: - with get_conn() as conn: - ws_count = conn.execute( - "SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,) - ).fetchone()[0] - if ws_count == 0: - # v5.2.0: first-launch → onboarding wizard - return RedirectResponse("/welcome", status_code=302) - except Exception: - logger.exception("dashboard") - return RedirectResponse("/local-workspace", status_code=302) - - # ── Gitea user → full dashboard ── - try: - repos = await gitea.get_user_repos(page=1, limit=50) - if search: - q = search.lower() - repos = [r for r in repos if q in r.get("full_name", "").lower() - or q in (r.get("description") or "").lower()] - if not show_archived: - repos = [r for r in repos if not r.get("archived", False)] - repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) - except Exception as e: - logger.error("Dashboard error: %s", e) - repos = [] - - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, repos) - template = env.get_template("dashboard.html") - return template.render(request=request, repos=repos, search=search, - show_archived=show_archived, **sidebar) - - -# ═══════════ Workspace ═══════════ - -@router.get("/workspace", response_class=HTMLResponse) -def workspace_page(request: Request): - """Unified workspace showing all projects.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return RedirectResponse("/auth/login?provider=local", status_code=302) - ctx = {**sidebar, "user": user} - # Pass active workspace for breadcrumb nav menu - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ctx["nav_workspace_id"] = ws["id"] if ws else 0 - template = env.get_template("workspace.html") - return template.render(**ctx) - - -@router.get("/gitea-workspace", response_class=HTMLResponse) -def gitea_workspace_page(request: Request): - """Gitea workspace — browse repo files.""" - import json - - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return RedirectResponse("/auth/login?provider=local", status_code=302) - owner = request.query_params.get("owner", "") - repo = request.query_params.get("repo", "") - ws_key = f"{owner}/{repo}" if owner and repo else "" - ws_name = ws_key or "Gitea Workspace" - # Auto-create local workspace mirror for storing local files - local_ws_id = None - if ws_key: - with get_conn() as conn: - existing = conn.execute( - "SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", - (user["id"], ws_key, "%gitea_repo%") - ).fetchone() - if existing: - local_ws_id = existing["id"] - else: - c = conn.execute( - "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", - (ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner})) - ) - local_ws_id = c.lastrowid - conn.execute( - "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", - (local_ws_id, user["id"], "admin") - ) - conn.commit() - ctx = { - **sidebar, - "user": user, - "active_ws_name": ws_name, - "workspace_key": ws_key, - "gitea_workspace": True, # always true on this page - "gitea_owner": owner, - "gitea_repo": repo, - "workspace_name": ws_name, - "workspace_initial": repo[0].upper() if repo else "G", - "owner": owner, - "repo": repo, - "nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu - } - template = env.get_template("gitea_workspace.html") - resp = HTMLResponse(content=template.render(**ctx)) - resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax") - return resp - - -@router.get("/api/workspace/projects") -async def list_workspace_projects(request: Request): - """List all projects: built-in + Gitea + GitHub. - Uses the user's own Gitea token if connected, not the global admin token.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - - builtin = [] - with get_conn() as conn: - rows = conn.execute( - "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" - ).fetchall() - # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne. - counts = {} - if rows: - for c in conn.execute( - "SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format( - ",".join("?" * len(rows)) - ), - [r["id"] for r in rows], - ).fetchall(): - counts[c["parent_id"]] = c["c"] - for r in rows: - builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"}) - - gitea_repos = [] - # Use per-user token if available, otherwise return empty - user_gitea = get_user_gitea_client(request) if user else None - if user_gitea: - try: - repos = await user_gitea.get_user_repos(page=1, limit=50) - for repo in repos: - gitea_repos.append({ - "id": str(repo.get("id", "")), - "name": repo.get("name", ""), - "full_name": repo.get("full_name", ""), - "description": repo.get("description", ""), - "html_url": repo.get("html_url", ""), - "language": repo.get("language", ""), - "forge": "gitea", - }) - except Exception: - logger.exception("list_workspace_projects") - - return {"builtin": builtin, "gitea": gitea_repos, "github": []} - - -@router.post("/api/workspace/projects") -def create_workspace_project(request: Request, body: dict = Body(default={})): - name = body.get("name", "").strip() - if not name: - return {"error": "Name required"} - with get_conn() as conn: - cursor = conn.execute( - "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')", - (name,), - ) - conn.commit() - pid = cursor.lastrowid - return {"id": pid, "name": name, "forge": "builtin"} - - -# ═══════════ Workspace Members API ═══════════ - -@router.get("/api/workspace/{ws_id:int}/members") -def list_members(request: Request, ws_id: int): - """List all members of a workspace.""" - with get_conn() as conn: - rows = conn.execute( - """SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at - FROM workspace_members wm JOIN users u ON u.id = wm.user_id - WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,) - ).fetchall() - return {"members": [dict(r) for r in rows]} - - -@router.post("/api/workspace/{ws_id:int}/members") -def invite_member(request: Request, ws_id: int, body: dict = Body(default={})): - """Invite a user to a workspace by email.""" - email = body.get("email", "").strip() - role = body.get("role", "editor") - if role not in ("owner", "admin", "editor", "viewer"): - return {"error": "Invalid role"}, 400 - with get_conn() as conn: - user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone() - if not user: - return {"error": "User not found"}, 404 - try: - conn.execute( - "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", - (ws_id, user["id"], role), - ) - conn.commit() - except Exception: - return {"error": "Already a member"}, 409 - return {"status": "ok", "user_id": user["id"], "role": role} - - -@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}") -def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})): - """Change a member's role.""" - role = body.get("role", "editor") - if role not in ("owner", "admin", "editor", "viewer"): - return {"error": "Invalid role"} - with get_conn() as conn: - conn.execute( - "UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", - (role, ws_id, user_id), - ) - conn.commit() - return {"status": "ok"} - - -@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") -def remove_member(request: Request, ws_id: int, user_id: int): - """Remove a member from a workspace.""" - with get_conn() as conn: - conn.execute( - "DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", - (ws_id, user_id), - ) - conn.commit() - return {"status": "ok"} - - -# ═══════════ Local Workspace (file/folder CRUD) ═══════════ - -@router.get("/local-workspace", response_class=HTMLResponse) -def local_workspace_page(request: Request, folder: int = None): - """Local workspace page with file/folder tree. - - If ?folder=ID is provided, shows that folder's contents with breadcrumb. - """ - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return RedirectResponse("/auth/login?provider=local", status_code=302) - - ws = _get_active_workspace(request, user_id=user["id"]) - ws_id = ws["id"] if ws else None - - # If no workspace exists for this user, redirect to workspaces page - if not ws_id: - return RedirectResponse("/workspaces", status_code=302) - - # Build breadcrumb if navigating into a folder - breadcrumb = [] - current_folder_id = folder - if folder and ws_id: - with get_conn() as conn: - breadcrumb = _build_breadcrumb(conn, folder) - - ctx = { - **sidebar, - "user": user, - "workspace_name": sidebar.get("active_ws_name", "My Workspace"), - "current_folder_id": current_folder_id or 0, - "workspace_id": ws_id or 0, - "nav_workspace_id": ws_id or 0, - "breadcrumb": breadcrumb, - "breadcrumbs": breadcrumb, - } - template = env.get_template("local_workspace.html") - return HTMLResponse( - content=template.render(**ctx), - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - } - ) - - -@router.get("/api/local-workspace/tree") -def local_workspace_tree(request: Request, folder: int = None): - """Return the file/folder tree filtered by active workspace. - - If ?folder=ID is provided, returns only that folder's children. - Otherwise returns the full recursive tree from root. - """ - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ws_id = ws["id"] if ws else None - if not ws_id: - return {"tree": [], "breadcrumb": []} - uid = _get_user_id(request) - - with get_conn() as conn: - if folder: - # Show only this folder's children + build breadcrumb - children = _build_tree_children(conn, folder, ws_id, uid) - breadcrumb = _build_breadcrumb(conn, folder) - return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder} - else: - # Full tree from root - roots = _build_tree_children(conn, None, ws_id, uid) - return {"tree": roots, "breadcrumb": [], "current_folder": None} - - -@router.get("/api/local-workspace/page-content/{page_id:int}") -def get_page_content(page_id: int): - """Return the raw content of a page (for preview).""" - with get_conn() as conn: - row = conn.execute( - "SELECT content, content_format FROM pages WHERE id=?", (page_id,) - ).fetchone() - if not row: - return JSONResponse({"error": "Not found"}, status_code=404) - fmt = row["content_format"] - if fmt == "file": - return JSONResponse({"content": "(uploaded file)", "format": fmt}) - # v6.5.0: resolve synced blocks server-side (fresh content on read). - from app.services.synced_blocks import resolve_content_json - return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt} - - -def _file_page_disk_path(page: dict): - """Resolve the on-disk file behind a ``content_format == 'file'`` page. - - Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None - when the row is not a file page, references a non-textual/missing file, or - the path escapes the data root (path-traversal guard). - """ - if (page.get("content_format") or "") != "file": - return None - import json as _json - try: - meta = _json.loads(page.get("content", "{}")) - except (_json.JSONDecodeError, TypeError): - meta = {} - if not isinstance(meta, dict): - return None - rel = (meta.get("file_path") or "").replace("\\", "/").strip() - if not rel or not rel.startswith("uploads/"): - return None - parts = rel.split("/") - if ".." in parts or "." in parts: - return None - from pathlib import Path - root = Path(settings.data_dir).resolve() - full = (root / rel).resolve() - try: - full.relative_to(root) - except ValueError: - return None - if not full.exists() or not full.is_file(): - return None - filename = parts[-1] or page.get("title", "file") - mime = meta.get("mime_type") or "application/octet-stream" - size = meta.get("size") or 0 - return (full, filename, mime, size) - - -def _require_page_view(request: Request, page_id: int) -> None: - """A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon).""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(401, "Authentication required") - from app.services.permission_manager import PermissionManager - - if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): - raise HTTPException(404, "Page not found") - - -@router.get("/api/pages/{page_id}/download") -def download_page_file(request: Request, page_id: int): - """Download the original uploaded file of a ``file`` page (attachment).""" - _require_page_view(request, page_id) - with get_conn() as conn: - row = conn.execute( - "SELECT id, title, content, content_format FROM pages " - "WHERE id=? AND deleted_at IS NULL", - (page_id,), - ).fetchone() - if not row: - return JSONResponse({"error": "Not found"}, status_code=404) - resolved = _file_page_disk_path(dict(row)) - if not resolved: - return JSONResponse({"error": "No downloadable file"}, status_code=404) - full, filename, mime, _size = resolved - from fastapi.responses import FileResponse - return FileResponse( - str(full), media_type=mime or "application/octet-stream", - filename=filename, content_disposition_type="attachment", - ) - - -@router.get("/api/pages/{page_id}/file-content") -def page_file_content(request: Request, page_id: int): - """Return the textual content of a ``file`` page (for copy to clipboard). - - Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is - only meaningful for plain-text / code / markdown files. - """ - from app.services.export import _file_text - - _require_page_view(request, page_id) - with get_conn() as conn: - row = conn.execute( - "SELECT id, title, content, content_format FROM pages " - "WHERE id=? AND deleted_at IS NULL", - (page_id,), - ).fetchone() - if not row: - return JSONResponse({"error": "Not found"}, status_code=404) - page = dict(row) - text = _file_text(page) - if text is None: - return JSONResponse( - {"ok": False, "error": "Not a textual file", "name": page.get("title", "")}, - status_code=415, - ) - return {"ok": True, "name": page.get("title") or "File", "content": text} - - -@router.get("/api/local-workspace/breadcrumb") -def local_workspace_breadcrumb(request: Request, folder: int): - """Return breadcrumb trail for a folder.""" - with get_conn() as conn: - breadcrumb = _build_breadcrumb(conn, folder) - return {"breadcrumb": breadcrumb} - - -def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list: - """Recursively build the tree of children for a node.""" - if parent_id is None: - rows = conn.execute( - "SELECT id, title, parent_section, content_format, content, page_icon, " - "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " - "created_at, updated_at FROM pages " - "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " - "ORDER BY created_at DESC", - (ws_id,), - ).fetchall() - else: - rows = conn.execute( - "SELECT id, title, parent_section, content_format, content, page_icon, " - "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " - "created_at, updated_at FROM pages " - "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " - "ORDER BY created_at DESC", - (parent_id, ws_id), - ).fetchall() - - # Get workspace owner name for author display - ws_owner = conn.execute( - "SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?", - (ws_id,), - ).fetchone() - author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—" - - # Collect all page IDs to fetch tags in one query - all_ids = [r["id"] for r in rows] - tags_map = {} - favorited_ids = set() - if all_ids: - placeholders = ",".join("?" for _ in all_ids) - tag_rows = conn.execute( - f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt " - f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})", - all_ids, - ).fetchall() - for tr in tag_rows: - tags_map.setdefault(tr["page_id"], []).append({ - "id": tr["id"], "name": tr["name"], "color": tr["color"], - }) - if uid is not None: - fav_rows = conn.execute( - f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})", - [uid, *all_ids], - ).fetchall() - favorited_ids = {fr["page_id"] for fr in fav_rows} - - tree = [] - for r in rows: - is_folder = r["parent_section"] == "Workspace" - children = _build_tree_children(conn, r["id"], ws_id, uid) - - # Compute size - size = 0 - if r["content_format"] == "file": - import json as _json - try: - meta = _json.loads(r["content"]) - size = meta.get("size", 0) - except Exception: - size = len(r["content"] or "") - else: - size = len(r["content"] or "") - - tree.append({ - "id": r["id"], - "name": r["title"] or "Untitled", - "type": "folder" if is_folder else "page", - "is_folder": is_folder, - "content_format": r["content_format"] if not is_folder else None, - "page_icon": r["page_icon"] or "", - "children": children, - "has_children": len(children) > 0, - "child_count": len(children), - "size": size, - "size_display": _format_size(size), - "created_at": r["created_at"], - "updated_at": r["updated_at"], - "author": author, - "tags": tags_map.get(r["id"], []), - "is_shared": bool(r["is_shared"]), - "favorited": r["id"] in favorited_ids, - }) - return tree - - -def _format_size(size_bytes: int) -> str: - """Human-readable file size.""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024 * 1024 * 1024: - return f"{size_bytes / (1024 * 1024):.1f} MB" - return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" - - -def _build_breadcrumb(conn, folder_id: int) -> list: - """Build breadcrumb trail from root to folder_id.""" - breadcrumb = [] - current = folder_id - seen = set() - while current and current not in seen: - seen.add(current) - row = conn.execute( - "SELECT id, title, parent_id, parent_section FROM pages WHERE id=?", - (current,), - ).fetchone() - if row: - breadcrumb.insert(0, { - "id": row["id"], - "name": row["title"] or "Untitled", - "is_folder": row["parent_section"] == "Workspace", - }) - current = row["parent_id"] - else: - break - return breadcrumb - - -def _nav_breadcrumb(conn, page_id: int) -> list: - """Build a Notion-style breadcrumb chain (root -> page) for the header. - - Returns a list of dicts: {id, label, url, icon, menu}. The last item is the - current page (url = None). Every item has ``menu: True`` so the header can - open a sibling-navigation dropdown for it. - """ - from app.routers.board import _file_icon - chain = [] - current = page_id - seen = set() - while current and current not in seen: - seen.add(current) - row = conn.execute( - "SELECT id, title, parent_id, parent_section, content_format " - "FROM pages WHERE id=? AND deleted_at IS NULL", - (current,), - ).fetchone() - if not row: - break - is_folder = row["parent_section"] == "Workspace" - title = row["title"] or "Untitled" - chain.insert(0, { - "id": row["id"], - "label": title, - "url": None, - "icon": "folder" if is_folder else _file_icon(title, row["content_format"]), - "menu": True, - }) - current = row["parent_id"] - # All items except the current page are navigable links. - for i, item in enumerate(chain): - if i < len(chain) - 1: - item["url"] = f"/pages/{item['id']}" - return chain - - -@router.get("/api/nav/menu") -def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None): - """Return the pages at one level for the header breadcrumb navigation menu. - - If ``parent_id`` is given, returns that page's children; otherwise the - workspace's root pages. Each item includes ``has_children`` so the frontend - can render an expandable sub-menu. - """ - from app.routers.board import _file_icon - ws_id = workspace_id - if not ws_id: - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ws_id = ws["id"] if ws else None - if not ws_id: - return {"items": []} - with get_conn() as conn: - if parent_id: - rows = conn.execute( - "SELECT id, title, parent_section, content_format FROM pages " - "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " - "ORDER BY sort_order ASC, created_at DESC", - (parent_id, ws_id), - ).fetchall() - else: - rows = conn.execute( - "SELECT id, title, parent_section, content_format FROM pages " - "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " - "ORDER BY sort_order ASC, created_at DESC", - (ws_id,), - ).fetchall() - ids = [r["id"] for r in rows] - child_counts = {} - if ids: - placeholders = ",".join("?" for _ in ids) - cc_rows = conn.execute( - f"SELECT parent_id, COUNT(*) AS c FROM pages " - f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL " - f"GROUP BY parent_id", - ids, - ).fetchall() - for cr in cc_rows: - child_counts[cr["parent_id"]] = cr["c"] - items = [] - for r in rows: - is_folder = r["parent_section"] == "Workspace" - title = r["title"] or "Untitled" - items.append({ - "id": r["id"], - "name": title, - "icon": "folder" if is_folder else _file_icon(title, r["content_format"]), - "has_children": child_counts.get(r["id"], 0) > 0, - "url": f"/pages/{r['id']}", - }) - return {"items": items} - - -@router.post("/api/local-workspace/items") -def create_local_workspace_item(request: Request, body: dict = Body(default={})): - """Create a new file in the active workspace.""" - name = (body.get("name") or "").strip() or "Untitled" - item_type = body.get("type", "page") - parent_id = body.get("parent_id") - explicit_ws_id = body.get("workspace_id") - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ws_id = explicit_ws_id or (ws["id"] if ws else None) - ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else "" - section = 'Workspace' if item_type == 'folder' else 'Private' - with get_conn() as conn: - cursor = conn.execute( - "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) " - "VALUES (?, ?, ?, '', 'blocks', ?, ?)", - (ws_key, ws_id, name, section, parent_id) - ) - conn.commit() - pid = cursor.lastrowid - return {"id": pid, "name": name, "type": item_type} - - -@router.put("/api/local-workspace/items/{item_id:int}") -def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})): - """Rename a file.""" - name = body.get("name", "Untitled").strip() - with get_conn() as conn: - conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id)) - conn.commit() - return {"status": "ok"} - - -@router.delete("/api/local-workspace/items/{item_id:int}") -def delete_local_workspace_item(request: Request, item_id: int): - """Soft-delete a file/folder (sets deleted_at).""" - from datetime import datetime - with get_conn() as conn: - conn.execute( - "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL", - (datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id), - ) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/local-workspace/items/{item_id:int}/restore") -def restore_local_workspace_item(request: Request, item_id: int): - """Restore a soft-deleted file/folder.""" - with get_conn() as conn: - conn.execute( - "UPDATE pages SET deleted_at=NULL WHERE id=?", - (item_id,), - ) - conn.commit() - return {"status": "ok"} - - -@router.get("/api/files/{ws_id:int}/{filename:path}") -def serve_uploaded_file(ws_id: int, filename: str): - """Serve an uploaded file from disk.""" - import mimetypes - from pathlib import Path - root = Path(settings.data_dir) - base_dir = (root / f"uploads/workspace_{ws_id}").resolve() - fp = (base_dir / filename).resolve() - try: - fp.relative_to(base_dir) - except ValueError: - return JSONResponse({"error": "Path traversal denied"}, status_code=403) - if not fp.exists(): - return JSONResponse({"error": "File not found"}, status_code=404) - mime, _ = mimetypes.guess_type(str(fp)) - content = fp.read_bytes() - from fastapi.responses import Response - return Response(content=content, media_type=mime or "application/octet-stream") - - -@router.put("/api/local-workspace/items/{item_id:int}/move") -def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})): - """Move an item to a new parent (drag & drop).""" - new_parent_id = body.get("parent_id") # None = move to root - with get_conn() as conn: - conn.execute( - "UPDATE pages SET parent_id=? WHERE id=?", - (new_parent_id, item_id), - ) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/local-workspace/upload") -async def upload_local_workspace_file(request: Request): - """Upload one or more files via drag-and-drop. - - Accepts multipart form with 'files' field (one or multiple files). - Optional: 'parent_id' to place files in a specific folder. - Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records. - """ - import json - from pathlib import Path - - uid = _require_user_id(request) # A22 : pas d'upload anonyme - ws = _get_active_workspace(request, user_id=uid) - ws_id = ws["id"] if ws else None - if not ws_id: - return JSONResponse({"error": "No active workspace"}, status_code=400) - - try: - form = await request.form() - except Exception: - return JSONResponse({"error": "Invalid form data"}, status_code=400) - - parent_id_raw = form.get("parent_id") - parent_id = int(parent_id_raw) if parent_id_raw else None - files = form.getlist("files") - - if not files: - return JSONResponse({"error": "No files provided"}, status_code=400) - - - from app.middleware.security import validate_upload - - data_root = Path(settings.data_dir) - upload_dir = data_root / f"uploads/workspace_{ws_id}" - upload_dir.mkdir(parents=True, exist_ok=True) - - results = [] - with get_conn() as conn: - for f in files: - filename = f.filename or "untitled" - # Sanitize filename: only keep basename, prevent path traversal - safe_name = Path(filename).name - if not safe_name: - safe_name = "untitled" - - # Unique filename on disk - file_path = upload_dir / safe_name - stem, suffix = file_path.stem, file_path.suffix - counter = 1 - while file_path.exists(): - file_path = upload_dir / f"{stem} ({counter}){suffix}" - counter += 1 - - content = await f.read() - err = validate_upload(safe_name, len(content)) # A22 : taille + extension - if err: - results.append({"name": safe_name, "error": err}) - continue - file_path.write_bytes(content) - - # Determine if this is a folder marker or actual file - rel_path = str(file_path.relative_to(data_root)) - size = len(content) - mime = f.content_type or "application/octet-stream" - - cursor = conn.execute( - """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) - VALUES ('', ?, ?, ?, 'file', 'Private', ?)""", - (ws_id, file_path.name, - json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}), - parent_id), - ) - results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size}) - - conn.commit() - - return {"status": "ok", "items": results} - - -@router.post("/api/local-workspace/upload-folder") -async def upload_local_workspace_folder(request: Request): - """Handle recursive folder upload. - - Frontend walks the directory tree with webkitGetAsEntry and sends: - - 'structure': JSON array of {path: str, type: 'folder'|'file'} - - 'files': multipart files (one per file in the structure) - - 'parent_id': target folder (optional) - - Creates folders first, then uploads files into their respective folders. - """ - import json - from pathlib import Path - - uid = _require_user_id(request) # A22 : pas d'upload anonyme - ws = _get_active_workspace(request, user_id=uid) - ws_id = ws["id"] if ws else None - if not ws_id: - return JSONResponse({"error": "No active workspace"}, status_code=400) - - try: - form = await request.form() - except Exception: - return JSONResponse({"error": "Invalid form data"}, status_code=400) - - parent_id_raw = form.get("parent_id") - root_parent_id = int(parent_id_raw) if parent_id_raw else None - structure_raw = form.get("structure") - - if not structure_raw: - return JSONResponse({"error": "No structure provided"}, status_code=400) - - try: - structure = json.loads(structure_raw) - except json.JSONDecodeError: - return JSONResponse({"error": "Invalid structure JSON"}, status_code=400) - - - from app.middleware.security import validate_upload - - data_root = Path(settings.data_dir) - upload_dir = data_root / f"uploads/workspace_{ws_id}" - upload_dir.mkdir(parents=True, exist_ok=True) - - results = [] - created_folders = {} # relative_path -> db_id - - with get_conn() as conn: - # Phase 1: Create all folders - for item in structure: - if item.get("type") != "folder": - continue - path_parts = item["path"].strip("/").split("/") - folder_name = path_parts[-1] - # Determine parent: parent of this folder in the tree - if len(path_parts) == 1: - actual_parent = root_parent_id - else: - parent_path = "/".join(path_parts[:-1]) - actual_parent = created_folders.get(parent_path) - - cursor = conn.execute( - """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) - VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""", - (ws_id, folder_name, actual_parent), - ) - fid = cursor.lastrowid - created_folders[item["path"].strip("/")] = fid - results.append({"id": fid, "name": folder_name, "type": "folder"}) - - # Phase 2: Upload files into their respective folders - for item in structure: - if item.get("type") != "file": - continue - path_parts = item["path"].strip("/").split("/") - file_name = path_parts[-1] - if len(path_parts) == 1: - file_parent = root_parent_id - else: - parent_path = "/".join(path_parts[:-1]) - file_parent = created_folders.get(parent_path) - - # Find the matching file in multipart data - matched = None - for f in form.getlist("files"): - if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)): - matched = f - break - if not matched: - continue - - safe_name = Path(file_name).name - file_path = upload_dir / safe_name - stem, suffix = file_path.stem, file_path.suffix - counter = 1 - while file_path.exists(): - file_path = upload_dir / f"{stem} ({counter}){suffix}" - counter += 1 - - content = await matched.read() - err = validate_upload(safe_name, len(content)) # A22 : taille + extension - if err: - results.append({"name": safe_name, "error": err}) - continue - file_path.write_bytes(content) - - rel_path = str(file_path.relative_to(data_root)) - cursor = conn.execute( - """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) - VALUES ('', ?, ?, ?, 'file', 'Private', ?)""", - (ws_id, file_path.name, - json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}), - file_parent), - ) - results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)}) - - conn.commit() - - return {"status": "ok", "items": results} - - -# ═══════════ Workspaces CRUD ═══════════ - -WORKSPACE_COOKIE = "flowdeck_workspace" - - -def _get_active_workspace(request: Request, user_id: int = None) -> dict | None: - """Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None.""" - ws_id = request.cookies.get(WORKSPACE_COOKIE) - if ws_id: - try: - with get_conn() as conn: - ws = conn.execute("SELECT * FROM workspaces WHERE id=?", - (int(ws_id),)).fetchone() - if ws: - ws_dict = dict(ws) - # Verify ownership — only return if it belongs to the current user - if user_id is None or ws_dict.get("owner_id") == user_id: - return ws_dict - except (ValueError, Exception): - pass - # Fallback: first workspace owned by this user - if user_id: - with get_conn() as conn: - ws = conn.execute( - "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", - (user_id,) - ).fetchone() - if ws: - return dict(ws) - return None - - -@router.get("/workspaces", response_class=HTMLResponse) -def workspaces_page(request: Request): - """Workspaces list page.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, [], include_workspace=False) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) - ctx = {**sidebar, "user": user} - # Pass active workspace for breadcrumb nav menu (null on workspaces home) - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ctx["nav_workspace_id"] = ws["id"] if ws else 0 - template = env.get_template("workspaces.html") - return template.render(**ctx) - - -@router.get("/api/workspaces") -def list_workspaces(request: Request): - """List all workspaces for the current user.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = user["id"] if user and user.get("id") else 1 - with get_conn() as conn: - rows = conn.execute( - "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count " - "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC", - (uid,), - ).fetchall() - workspaces = [] - for r in rows: - d = dict(r) - workspaces.append(d) - active = _get_active_workspace(request, user_id=_get_user_id(request)) - return {"workspaces": workspaces, "active_id": active["id"] if active else None} - - -@router.post("/api/workspaces") -def create_workspace(request: Request, body: dict = Body(default={})): - """Create a new workspace.""" - name = body.get("name", "New Workspace").strip() - if not name: - return {"error": "Name required"} - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = user["id"] if user and user.get("id") else 1 - with get_conn() as conn: - # Ensure user exists (FK constraint) - uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone() - if not uid_ok: - conn.execute( - "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)", - (uid, user.get("login", "admin") if user else "admin", - user.get("full_name", "Admin") if user else "Admin"), - ) - cursor = conn.execute( - "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", - (name, uid), - ) - ws_id = cursor.lastrowid - # Add owner as member - conn.execute( - "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", - (ws_id, uid), - ) - conn.commit() - return {"id": ws_id, "name": name} - - -@router.put("/api/workspaces/{ws_id:int}") -def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})): - """Rename a workspace.""" - name = body.get("name", "").strip() - if not name: - return {"error": "Name required"} - with get_conn() as conn: - conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id)) - conn.commit() - return {"status": "ok"} - - -@router.delete("/api/workspaces/{ws_id:int}") -def delete_workspace(request: Request, ws_id: int): - """Delete a workspace and all its pages.""" - with get_conn() as conn: - conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) - conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,)) - conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/workspaces/{ws_id:int}/select") -def select_workspace(request: Request, ws_id: int): - """Set the active workspace via cookie.""" - from fastapi.responses import JSONResponse - response = JSONResponse({"status": "ok", "workspace_id": ws_id}) - response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/") - return response - - -# ═══════════ Settings Page ═══════════ - -@router.get("/settings", response_class=HTMLResponse) -def app_settings_page(request: Request): - """Settings & configuration page.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return RedirectResponse("/auth/login?provider=local", status_code=302) - ctx = {**sidebar, "user": user} - # Pass active workspace for breadcrumb nav menu - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ctx["nav_workspace_id"] = ws["id"] if ws else 0 - template = env.get_template("settings.html") - return template.render(**ctx) - - -@router.post("/api/settings/avatar") -async def upload_avatar(request: Request): - """Upload a user avatar image.""" - import os - import uuid - from pathlib import Path - form = await request.form() - file = form.get("file") - if not file: - return {"error": "No file"}, 400 - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return {"error": "Not authenticated"}, 401 - # Save to data/avatars - avatars_dir = Path("/data/avatars") - avatars_dir.mkdir(parents=True, exist_ok=True) - ext = os.path.splitext(file.filename)[1] or ".png" - filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}" - filepath = avatars_dir / filename - content = await file.read() - filepath.write_bytes(content) - # Update user avatar_url - avatar_url = f"/api/settings/avatar/{filename}" - with get_conn() as conn: - conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"])) - conn.commit() - return {"avatar_url": avatar_url} - - -@router.get("/api/settings/avatar/{filename:path}") -def serve_avatar_file(filename: str): - """Serve an uploaded avatar image file.""" - from pathlib import Path - - from fastapi.responses import FileResponse - # A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette - # accepte les `/`, donc `..%2f` ressortirait du dossier avatars. - base_dir = Path("/data/avatars").resolve() - filepath = (base_dir / filename).resolve() - try: - filepath.relative_to(base_dir) - except ValueError: - return JSONResponse({"error": "Path traversal denied"}, status_code=403) - if not filepath.is_file(): - return JSONResponse({"error": "Not found"}, status_code=404) - return FileResponse(filepath) - - -@router.get("/api/avatar/{user_id:int}") -def get_avatar(user_id: int): - """Redirect to the user's avatar.""" - with get_conn() as conn: - row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() - if row and row["avatar_url"]: - return RedirectResponse(row["avatar_url"], status_code=302) - return JSONResponse({"error": "No avatar"}, status_code=404) - - -@router.post("/api/settings/avatar-color") -def set_avatar_color(request: Request, body: dict = Body(default={})): - """Set the user's avatar background color.""" - color = body.get("color", "#3A3A3A") - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return {"error": "Not authenticated"}, 401 - with get_conn() as conn: - conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"])) - conn.commit() - return {"status": "ok", "color": color} - - -# ═══════════ Tag Management API (per-user) ═══════════ - -@router.post("/api/settings/tags") -def create_tag_global(request: Request, body: dict = Body(default={})): - """Create a tag for the current user.""" - tag_name = body.get("name", "").strip().lower() - color = body.get("color", "#787774") - uid = _get_user_id(request) - if not tag_name or not uid: - return {"error": "Tag name required"}, 400 - with get_conn() as conn: - tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() - if tag: - conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"])) - conn.commit() - return {"tag": {"id": tag["id"], "name": tag_name, "color": color}} - cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid)) - conn.commit() - return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}} - - -@router.put("/api/settings/tags/{tag_id:int}") -def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})): - """Update a tag (name or color) — only if owned by user.""" - uid = _get_user_id(request) - with get_conn() as conn: - if "name" in body: - conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid)) - if "color" in body: - conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid)) - conn.commit() - return {"status": "ok"} - - -@router.delete("/api/settings/tags/{tag_id:int}") -def delete_tag_global(tag_id: int, request: Request): - """Delete a tag — only if owned by user.""" - uid = _get_user_id(request) - with get_conn() as conn: - conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,)) - conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid)) - conn.commit() - return {"status": "ok"} - - -@router.get("/api/settings/tags/all") -def list_all_tags_global(request: Request): - """List current user's tags with counts.""" - uid = _get_user_id(request) - with get_conn() as conn: - rows = conn.execute( - "SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count " - "FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id " - "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", - (uid,), - ).fetchall() - return JSONResponse( - {"tags": [dict(r) for r in rows]}, - headers={"Cache-Control": "no-store"}, - ) - - -# ═══════════ Workspace Tags API ═══════════ - -@router.get("/api/local-workspace/tags") -def list_tags(request: Request): - """List ALL user tags with counts scoped to the active workspace.""" - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ws_id = ws["id"] if ws else None - uid = _get_user_id(request) - if not ws_id: - return {"tags": []} - with get_conn() as conn: - rows = conn.execute( - "SELECT t.id, t.name, t.color, " - "(SELECT COUNT(*) FROM page_tags pt " - " JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " - " WHERE pt.tag_id = t.id) as count " - "FROM tags t WHERE t.user_id = ? ORDER BY t.name", - (ws_id, uid), - ).fetchall() - return JSONResponse( - {"tags": [dict(r) for r in rows]}, - headers={"Cache-Control": "no-store"}, - ) - - -@router.get("/api/local-workspace/items/{item_id:int}/tags") -def get_item_tags(item_id: int): - """Get tags for a specific item.""" - with get_conn() as conn: - rows = conn.execute( - "SELECT t.id, t.name, t.color FROM tags t " - "JOIN page_tags pt ON pt.tag_id = t.id " - "WHERE pt.page_id = ? ORDER BY t.name", - (item_id,), - ).fetchall() - return {"tags": [dict(r) for r in rows]} - - -@router.post("/api/local-workspace/items/{item_id:int}/tags") -def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})): - """Add a tag to an item (creates tag if new, scoped to user).""" - tag_name = body.get("name", "").strip().lower() - tag_color = body.get("color", "#787774") - uid = _get_user_id(request) - if not tag_name: - return {"error": "Tag name required"}, 400 - - with get_conn() as conn: - # Get or create tag (per user) - tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() - if not tag: - cursor = conn.execute( - "INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid) - ) - conn.commit() - tag_id = cursor.lastrowid - tag = {"id": tag_id, "name": tag_name, "color": tag_color} - else: - tag_id = tag["id"] - - # Link tag to page (ignore duplicate) - try: - conn.execute( - "INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", - (item_id, tag_id), - ) - conn.commit() - except Exception: - logger.exception("add_item_tag") - - return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} - - -@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") -def remove_item_tag(item_id: int, tag_id: int): - """Remove a tag from an item.""" - with get_conn() as conn: - conn.execute( - "DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?", - (item_id, tag_id), - ) - conn.commit() - return {"status": "ok"} - - -@router.get("/api/local-workspace/tags/search") -def search_by_tags(request: Request, tags: str = ""): - """Search items by tags (comma-separated).""" - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - ws_id = ws["id"] if ws else None - if not ws_id: - return {"items": []} - tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()] - if not tag_names: - return {"items": []} - with get_conn() as conn: - placeholders = ",".join("?" for _ in tag_names) - rows = conn.execute( - f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, " - f"p.content, p.created_at, p.updated_at " - f"FROM pages p " - f"JOIN page_tags pt ON pt.page_id = p.id " - f"JOIN tags t ON t.id = pt.tag_id " - f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL " - f"ORDER BY p.updated_at DESC", - tag_names + [ws_id], - ).fetchall() - items = [] - for r in rows: - is_folder = r["parent_section"] == "Workspace" - size = len(r["content"] or "") - items.append({ - "id": r["id"], - "name": r["title"] or "Untitled", - "is_folder": is_folder, - "content_format": r["content_format"], - "created_at": r["created_at"], - "updated_at": r["updated_at"], - "size": size, - "size_display": _format_size(size), - }) - return {"items": items} - - -# ── Account update ── -@router.put("/api/settings/account") -def update_account(request: Request, body: dict = Body(default={})): - """Update current user's profile: full_name, login, email, password.""" - from app.password_utils import hash_password - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return JSONResponse({"error": "Not authenticated"}, status_code=401) - with get_conn() as conn: - uid = user["id"] - if "full_name" in body: - conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid)) - if "login" in body: - new_login = body["login"].strip() - if new_login and new_login != user.get("login"): - existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone() - if existing: - return JSONResponse({"error": "Username already taken"}, status_code=409) - conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid)) - if "email" in body: - conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid)) - if "password" in body and body["password"].strip(): - pw = body["password"].strip() - if len(pw) < 6: - return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) - conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid)) - conn.commit() - row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() - user_data = dict(row) - # Refresh session cookie with updated data (keeps the same session id) - cookie = request.cookies.get("flowdeck_session", "") - new_session = SessionManager.refresh_session(cookie, user_data, request) - response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}}) - response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/") - return response - - -# ═══════════ Sidebar Refresh API ═══════════ - -@router.get("/api/sidebar/workspace-tree") -def sidebar_workspace_tree(request: Request): - """Return the sidebar workspace tree as HTML fragment. - - Called by appState().refreshSidebarTree() after CRUD operations - in the main content area to keep the sidebar in sync. - """ - from app.routers.board import _load_workspace_pages - from app.templating import ENV - - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - return HTMLResponse("") - - ws_cookie = request.cookies.get("flowdeck_workspace", "") - if not ws_cookie: - return HTMLResponse('') - - # Gitea workspace — no server-side tree, loaded client-side - if ws_cookie.startswith("gitea:"): - return HTMLResponse('') - - try: - ws_id = int(ws_cookie) - with get_conn() as conn: - # Verify workspace belongs to user - row = conn.execute( - "SELECT id FROM workspaces WHERE id=? AND owner_id=?", - (ws_id, user["id"]) - ).fetchone() - if not row: - return HTMLResponse( - '' - ) - - pages = _load_workspace_pages(ws_cookie) - if not pages: - return HTMLResponse( - '' - ) - - # Render the tree using the extracted macro - env = ENV - template = env.from_string( - "{% from '_workspace_tree_macro.html' import render_workspace_tree %}" - "{{ render_workspace_tree(pages) }}" - ) - html = template.render(pages=pages) - return HTMLResponse(html) - except (ValueError, Exception) as e: - logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True) - return HTMLResponse( - '' - ) - - -# ═══════════ Public Published Page ═══════════ - - -@router.get("/p/{slug}", response_class=HTMLResponse) -def public_published_page(request: Request, slug: str): - """Serve a published page at /p/ — no auth required.""" - from app.templating import ENV - - with get_conn() as conn: - row = conn.execute( - "SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon " - "FROM pages WHERE publish_slug=? AND is_published=1", - (slug,), - ).fetchone() - - if not row: - return HTMLResponse( - """ -Not Found — FlowDeck -

404

""", - status_code=404, - ) - - page = dict(row) - env = ENV - - # Convert blocks to HTML for rendering - content_html = "" - if page.get("content_format") == "blocks" and page.get("content"): - import json as _json - try: - blocks = _json.loads(page["content"]) - from app.services.synced_blocks import resolve_synced_block - blocks = resolve_synced_block(blocks) - from app.db import get_conn as _gc - from app.services.wiki_links import token_labels - with _gc() as conn: - wiki_titles_map = token_labels(conn, page["content"]) - content_html = _render_blocks_public(blocks, wiki_titles_map) - except (_json.JSONDecodeError, Exception): - content_html = f"

{page.get('content', '')}

" - elif page.get("content"): - # Plain text / markdown - text = page["content"] - content_html = f"
{text}
" - - template = env.get_template("public_page.html") - return template.render( - title=page["title"] or "Untitled", - content_html=content_html, - updated_at=page.get("updated_at", ""), - created_at=page.get("created_at", ""), - cover_url=page.get("cover_url", ""), - page_icon=page.get("page_icon", ""), - ) - - -def _sanitize_id(block_id: str) -> str: - """Sanitize a block id for use as an HTML anchor (only alnum kept).""" - if not block_id: - return "" - return "".join(ch for ch in str(block_id) if ch.isalnum()) - - -def _render_blocks_public(blocks: list, titles: dict | None = None) -> str: - """Render FlowDeck blocks as plain HTML for public pages. - - v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns - ``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links. - """ - html_parts = [] - - def _wiki(c: str) -> str: - if titles and ("[[fdpage:" in c or "[[fddate:" in c): - from app.services.wiki_links import resolve_tokens_html - return resolve_tokens_html(c, titles) - return c - - for b in blocks: - t = b.get("type", "paragraph") - c = _wiki(b.get("content", "") or "") - if t == "heading_1": - html_parts.append(f'

{c}

') - elif t == "heading_2": - html_parts.append(f'

{c}

') - elif t == "heading_3": - html_parts.append(f'

{c}

') - elif t == "heading_4": - html_parts.append(f'

{c}

') - elif t == "bulleted_list": - html_parts.append(f'
  • {c}
  • ') - elif t == "numbered_list": - html_parts.append(f'
  • {c}
  • ') - elif t == "to_do": - checked = "checked" if b.get("checked") else "" - todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else "" - html_parts.append( - f'
    ' - f'' - f'{c}' - f'
    ' - ) - elif t == "toggle": - children_html = "" - if b.get("children"): - children_html = '
    ' - children_html += _render_blocks_public(b["children"], titles) - children_html += "
    " - html_parts.append( - f'
    {c}{children_html}
    ' - ) - elif t == "quote": - html_parts.append( - f'
    {c}
    ' - ) - elif t == "table_of_contents": - toc = [ - x for x in blocks - if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip() - ] - if toc: - items = [] - for h in toc: - lvl = int(h["type"].split("_")[-1]) - items.append( - f'
    ' - f'{h.get("content","")}
    ' - ) - html_parts.append( - '
    ' - '
    On this page
    ' - + "".join(items) + "
    " - ) - elif t == "math": - tex = c.replace("&", "&").replace("<", "<").replace(">", ">") - html_parts.append( - f'
    ' - ) - elif t == "columns": - cols_html = "" - for child in b.get("children") or []: - cols_html += ( - '
    ' - + _render_blocks_public([child], titles) + "
    " - ) - html_parts.append( - f'
    {cols_html}
    ' - ) - elif t == "callout": - icon = b.get("icon", "💡") - bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)") - html_parts.append( - f'
    ' - f'{icon}' - f'{c}
    ' - ) - elif t == "code": - lang = b.get("language", "") - lang_label = f"
    {lang}
    " if lang else "" - html_parts.append( - f'
    '
    -                f'{lang_label}'
    -                f'{c}
    ' - ) - elif t == "divider": - html_parts.append('
    ') - elif t == "image": - src = b.get("src", "") - alt = b.get("alt", "") - html_parts.append( - f'
    ' - f'{alt}' - f'
    ' - ) - elif t == "video": - src = b.get("src", "") - if src: - html_parts.append( - f'' - ) - elif t == "audio": - src = b.get("src", "") - if src: - html_parts.append( - f'' - ) - elif t == "bookmark": - url = b.get("url") or b.get("src") or "" - title = b.get("title") or url - desc = b.get("description") or "" - img = b.get("image") or "" - site = b.get("site_name") or "" - img_html = ( - f'' if img else "" - ) - desc_html = f'
    {desc}
    ' if desc else "" - site_html = f'
    {site}
    ' if site else "" - html_parts.append( - f'' - f'
    ' - f'
    {title}
    ' - f'{desc_html}{site_html}
    {img_html}
    ' - ) - elif t == "embed": - url = b.get("src", "") - emb = b.get("embed_type") or "" - if emb in ("inline_dbs", "collection"): - html_parts.append('
    [Embedded content]
    ') - elif emb == "download": - html_parts.append( - f'⬇ {b.get("file_name") or "Download"}' - ) - elif emb == "pdf" and url: - html_parts.append( - f'' - ) - elif url: - from app.services.embeds import embed_src - src = b.get("embed_src") or embed_src(url) or url - height = b.get("height") or 520 - try: - height = int(height) - except (ValueError, TypeError): - height = 520 - html_parts.append( - f'
    ' - ) - elif t == "synced": - # v6.5.0: render synced block instances (resolved server-side). - if b.get("_synced_deleted"): - html_parts.append( - '
    ' - 'Deleted synced block
    ' - ) - else: - inner = b.get("_synced_content") - if not isinstance(inner, list) or not inner: - try: - import json as _sj - parsed = _sj.loads(b.get("content") or "[]") - inner = parsed if isinstance(parsed, list) else [] - except (ValueError, TypeError): - inner = [] - inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x - for x in inner] - if inner: - html_parts.append( - '
    ' - + _render_blocks_public(inner, titles) + '
    ' - ) - else: - html_parts.append(f'

    {c}

    ') - return "\n".join(html_parts) - - -# ═══════════ Library page actions API ═══════════ - -@router.get("/api/pages/{page_id:int}/content") -def api_page_content(page_id: int): - """Get page content for side peek preview.""" - with get_conn() as conn: - row = conn.execute( - "SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL", - (page_id,), - ).fetchone() - if not row: - return JSONResponse({"error": "Not found"}, status_code=404) - # v6.5.0: resolve synced blocks server-side (fresh content on read). - from app.services.synced_blocks import resolve_content_json - return { - "title": row["title"], - "content": resolve_content_json(row["content"], row["content_format"]), - "format": row["content_format"] or "blocks", - } - - -@router.put("/api/pages/{page_id:int}/rename") -def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})): - """Inline rename a page title.""" - title = (body.get("title") or "").strip() - if not title: - return JSONResponse({"error": "Title required"}, status_code=400) - with get_conn() as conn: - conn.execute( - "UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", - (title, page_id), - ) - # v6.5.0: renaming a database row's content page updates the row. - from app.services.row_pages import sync_page_title_to_row - sync_page_title_to_row(conn, page_id) - conn.commit() - return {"status": "ok", "title": title} - - -@router.post("/api/pages/{page_id:int}/trash") -def api_trash_page(page_id: int): - """Soft-delete a page (move to trash).""" - with get_conn() as conn: - conn.execute( - "UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?", - (page_id,), - ) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/pages/{page_id:int}/convert-to-database") -def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})): - """Convert a page into a full-page database (Notion-style). - - Creates a collection linked to this page, adds the default 'Name' property, - and sets the page's content_format to 'collection'. - """ - import json as _json - db_name = (body.get("name") or "").strip() - - with get_conn() as conn: - page = conn.execute( - "SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", - (page_id,), - ).fetchone() - if not page: - return JSONResponse({"error": "Page not found"}, status_code=404) - - if not db_name: - db_name = page["title"] or "New Database" - - # Create the collection - cur = conn.execute( - """INSERT INTO collections - (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) - VALUES (?, '', '📋', '[]', 0, ?, ?)""", - (db_name, page_id, page["workspace_id"]), - ) - collection_id = cur.lastrowid - - # Create default "Name" property (text, position 0) - conn.execute( - """INSERT INTO collection_properties - (collection_id, name, prop_type, position, required, visible_in_views) - VALUES (?, 'Name', 'title', 0, 1, 1)""", - (collection_id,), - ) - - # Create default "Table" view - conn.execute( - """INSERT INTO collection_views - (collection_id, name, view_type, config_json, position) - VALUES (?, 'Table', 'table', ?, 0)""", - (collection_id, _json.dumps({"visible_properties": ["Name"]})), - ) - - # Update the page to be a database page - conn.execute( - "UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (collection_id, page_id), - ) - - conn.commit() - - return { - "status": "converted", - "collection_id": collection_id, - "name": db_name, - "view_url": f"/pages/{page_id}", - } - - -@router.get("/api/collections/{collection_id:int}/table-data") -def api_collection_table_data(collection_id: int): - """Get collection properties + pages for rendering the table view.""" - with get_conn() as conn: - coll = conn.execute( - "SELECT * FROM collections WHERE id=?", (collection_id,) - ).fetchone() - if not coll: - return JSONResponse({"error": "Collection not found"}, status_code=404) - - properties = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - - pages = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - - views = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - - return { - "collection": dict(coll), - "properties": properties, - "pages": pages, - "views": views, - } - - -@router.post("/api/collections/{collection_id:int}/pages") -def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})): - """Create a new page (row) in a collection.""" - import json as _json - title = body.get("title", "New page").strip() or "New page" - icon = body.get("icon", "file") - - with get_conn() as conn: - coll = conn.execute( - "SELECT id FROM collections WHERE id=?", (collection_id,) - ).fetchone() - if not coll: - return JSONResponse({"error": "Collection not found"}, status_code=404) - - # Get next position - max_pos = conn.execute( - "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", - (collection_id,), - ).fetchone()[0] - - # Load default property values from collection properties - props = [ - dict(r) for r in conn.execute( - "SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - default_values = {} - for p in props: - if p["prop_type"] == "title": - default_values[str(p["id"])] = title - # Caller-provided values (e.g. board "add card in column X") win. - incoming = body.get("properties") or {} - if isinstance(incoming, dict): - default_values.update(incoming) - - from app.services.property_types import apply_auto_properties - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1} - apply_auto_properties(props, default_values, user, is_create=True) - - cur = conn.execute( - """INSERT INTO collection_pages - (collection_id, title, icon, cover_url, position, property_values_json) - VALUES (?, ?, ?, ?, ?, ?)""", - (collection_id, title, icon, body.get("cover_url", ""), max_pos, - _json.dumps(default_values)), - ) - page_id = cur.lastrowid - conn.commit() - - return { - "id": page_id, - "title": title, - "icon": icon, - "position": max_pos, - "property_values_json": default_values, - "status": "created", - } diff --git a/app/routers/dashboard/__init__.py b/app/routers/dashboard/__init__.py new file mode 100644 index 0000000..795d06a --- /dev/null +++ b/app/routers/dashboard/__init__.py @@ -0,0 +1,77 @@ +"""FlowDeck — Dashboard (pages HTML + API de l'app). + +Découpe A28 : l'ancien `dashboard.py` (2 735 lignes, 63 routes) est +devenu ce package — un module par concern, helpers dans `_common`, +re-export de tout ce que les 7 importateurs existants utilisent +(main, board, my_tasks, web_clipper, wiki, sites, tests). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter + +from . import ( # ordre = ordre d'enregistrement d'origine (openapi identique) + account_api, + account_settings, + local_workspace, + pages_api, + pages_html, + public, + workspace, + workspaces, +) +from ._common import ( # noqa: F401 — re-export des helpers + _VERSION, + WORKSPACE_COOKIE, + _build_breadcrumb, + _build_tree_children, + _file_page_disk_path, + _format_size, + _get_active_workspace, + _get_app_version, + _get_user_id, + _get_user_or_redirect, + _local_workspaces_for_user, + _nav_breadcrumb, + _render_blocks_public, + _require_page_view, + _require_user_id, + _sanitize_id, + _sidebar_data, +) + +logger = logging.getLogger(__name__) +router = APIRouter() +for _mod in ( + pages_html, + account_api, + workspace, + local_workspace, + workspaces, + account_settings, + public, + pages_api, +): + router.include_router(_mod.router) + +__all__ = [ + "router", + "WORKSPACE_COOKIE", + "_VERSION", + "_build_breadcrumb", + "_build_tree_children", + "_file_page_disk_path", + "_format_size", + "_get_active_workspace", + "_get_app_version", + "_get_user_id", + "_get_user_or_redirect", + "_local_workspaces_for_user", + "_nav_breadcrumb", + "_render_blocks_public", + "_require_page_view", + "_require_user_id", + "_sanitize_id", + "_sidebar_data", +] diff --git a/app/routers/dashboard/_common.py b/app/routers/dashboard/_common.py new file mode 100644 index 0000000..5896092 --- /dev/null +++ b/app/routers/dashboard/_common.py @@ -0,0 +1,774 @@ +"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28). + +Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état : +_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package. +""" +from __future__ import annotations + +import logging + +from fastapi import HTTPException, Request +from fastapi.responses import RedirectResponse + +from app.auth.session import SessionManager +from app.config import settings +from app.db import get_conn + +logger = logging.getLogger(__name__) + +_VERSION = None + +WORKSPACE_COOKIE = "flowdeck_workspace" + + + +def _get_app_version() -> str: + """Read version from VERSION file with caching.""" + global _VERSION + if _VERSION is not None: + return _VERSION + try: + import os + version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION") + if os.path.exists(version_path): + with open(version_path) as f: + _VERSION = f.read().strip() + else: + # Docker fallback + version_path = "/app/VERSION" + if os.path.exists(version_path): + with open(version_path) as f: + _VERSION = f.read().strip() + else: + _VERSION = "0.0.0" + except Exception: + _VERSION = "0.0.0" + return _VERSION + + + + + + +def _get_user_or_redirect(request: Request): + """Return decoded user or a RedirectResponse to login page. + Skips redirect when DB has no users (fresh install / test env).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + # Allow through if no users exist yet (fresh install / tests) + try: + with get_conn() as conn: + count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if count == 0: + return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} + except Exception: + logger.exception("_get_user_or_redirect") + return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) + return user + + + + + + +def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: + """Return list of local workspaces for a user.""" + if not user: + return [] + try: + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", + (user["id"],) + ).fetchall() + return [{"id": r["id"], "name": r["name"]} for r in rows] + except Exception: + return [] + + + + + + +def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + ws = user.get("login", "Bruno") if user else "Bruno" + initial = ws[0].upper() if ws else "B" + + # Get avatar info from DB + avatar_url = "" + avatar_color = "#3A3A3A" + if user: + try: + with get_conn() as conn: + row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone() + if row: + avatar_url = row["avatar_url"] or "" + avatar_color = row["avatar_color"] or "#3A3A3A" + except Exception: + logger.exception("_sidebar_data") + + recent_pages = [] + for repo in repos[:10]: + full_name = repo.get("full_name", "") + recent_pages.append({ + "id": full_name, + "name": repo.get("name", full_name), + "icon": "folder", + "url": f"/board/{full_name}", + "active": False, + "indent": 0, + "depth": 0, + "has_children": False, + "children": [], + }) + + # Active workspace from cookie (skip on pages like /workspaces where no + # workspace context should be shown) + from app.routers.board import _load_workspace_pages + ws_cookie = request.cookies.get("flowdeck_workspace", "") + active_ws_name = "Workspace" + workspace_pages = [] + gitea_workspace = False + gitea_owner = "" + gitea_repo = "" + has_active_workspace = False + local_ws_id = 0 + + if ws_cookie and ws_cookie.startswith("gitea:"): + # Gitea workspace: set owner/repo for client-side tree loading + # AND open the local workspace mirror of the same name in the + # sidebar's top "My Workspaces" section, in parallel with the + # Gitea repository tree. + parts = ws_cookie.split(":", 2) + if len(parts) >= 3: + gitea_owner = parts[1] + gitea_repo = parts[2] + active_ws_name = f"{gitea_owner}/{gitea_repo}" + gitea_workspace = True + has_active_workspace = True + # Load the local mirror workspace tree so it appears in "My + # Workspaces" alongside the Gitea repository section. + if user: + try: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") + ).fetchone() + if row: + local_ws_id = row["id"] + workspace_pages = _load_workspace_pages(str(local_ws_id)) + except (ValueError, Exception): + pass + elif include_workspace and ws_cookie and user: + try: + wsi = int(ws_cookie) + with get_conn() as conn: + # Verify this workspace belongs to the current user + row = conn.execute( + "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", + (wsi, user["id"]) + ).fetchone() + if row: + active_ws_name = row["name"] + workspace_pages = _load_workspace_pages(ws_cookie) + has_active_workspace = True + # else: stale cookie from another user — ignore + except (ValueError, Exception): + pass + + # Auth method & OAuth badge data + auth_method = "local" + gitea_linked = False + github_linked = False + if user and user.get("id"): + try: + with get_conn() as conn: + am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() + if am_row and am_row["auth_method"]: + auth_method = am_row["auth_method"] + tokens = conn.execute( + "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) + ).fetchall() + for t in tokens: + if t["provider"] == "gitea": + gitea_linked = True + elif t["provider"] == "github": + github_linked = True + except Exception: + logger.exception("_sidebar_data") + + # Get local workspace ID for Gitea workspace mirror + local_ws_id = 0 + if gitea_workspace and gitea_owner and gitea_repo: + try: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") + ).fetchone() + if row: + local_ws_id = row["id"] + except Exception: + logger.exception("_sidebar_data") + + # Private pages for mirror workspace (when Gitea remote active) + private_pages = [] + if gitea_workspace and local_ws_id: + try: + with get_conn() as conn: + pp_rows = conn.execute( + "SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20", + (local_ws_id,) + ).fetchall() + private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] + except Exception: + logger.exception("_sidebar_data") + + # Shared / received / published pages for the sidebar (Par moi / Avec moi) + shared_made_pages = [] + shared_received_pages = [] + published_pages = [] + shared_pages = [] + if user and user.get("id"): + from app.routers.board import _load_shared_sidebar_pages + shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"]) + + sidebar = { + "workspace_name": ws, "workspace_initial": initial, + "active_ws_name": active_ws_name, + "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", + "gitea_workspace": gitea_workspace, + "gitea_owner": gitea_owner, + "gitea_repo": gitea_repo, + "local_ws_id": local_ws_id, + "workspace_pages": workspace_pages, + "current_page": "Dashboard", "last_edited": "now", + "recent_pages": recent_pages, + "private_pages": private_pages, + "favorite_pages": [], + "shared_pages": shared_pages, + "shared_made_pages": shared_made_pages, + "shared_received_pages": shared_received_pages, + "published_pages": published_pages, + "user": user, + "avatar_url": avatar_url, + "avatar_color": avatar_color, + "auth_method": auth_method, + "gitea_linked": gitea_linked, + "github_linked": github_linked, + "has_active_workspace": has_active_workspace, + "app_version": _get_app_version(), + } + + sidebar["local_workspaces"] = _local_workspaces_for_user(user) + + return sidebar + + + + + + +# ═══════════ User API endpoints ═══════════ + +def _get_user_id(request: Request) -> int: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + return user["id"] if user and user.get("id") else 1 + + + + + + +def _require_user_id(request: Request) -> int: + """A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent + plus le fallback « legacy single-user » → id 1 = l'admin seedé).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + return user["id"] + + + + + + +def _file_page_disk_path(page: dict): + """Resolve the on-disk file behind a ``content_format == 'file'`` page. + + Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None + when the row is not a file page, references a non-textual/missing file, or + the path escapes the data root (path-traversal guard). + """ + if (page.get("content_format") or "") != "file": + return None + import json as _json + try: + meta = _json.loads(page.get("content", "{}")) + except (_json.JSONDecodeError, TypeError): + meta = {} + if not isinstance(meta, dict): + return None + rel = (meta.get("file_path") or "").replace("\\", "/").strip() + if not rel or not rel.startswith("uploads/"): + return None + parts = rel.split("/") + if ".." in parts or "." in parts: + return None + from pathlib import Path + root = Path(settings.data_dir).resolve() + full = (root / rel).resolve() + try: + full.relative_to(root) + except ValueError: + return None + if not full.exists() or not full.is_file(): + return None + filename = parts[-1] or page.get("title", "file") + mime = meta.get("mime_type") or "application/octet-stream" + size = meta.get("size") or 0 + return (full, filename, mime, size) + + + + + + +def _require_page_view(request: Request, page_id: int) -> None: + """A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + from app.services.permission_manager import PermissionManager + + if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): + raise HTTPException(404, "Page not found") + + + + + + +def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list: + """Recursively build the tree of children for a node.""" + if parent_id is None: + rows = conn.execute( + "SELECT id, title, parent_section, content_format, content, page_icon, " + "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " + "created_at, updated_at FROM pages " + "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " + "ORDER BY created_at DESC", + (ws_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, title, parent_section, content_format, content, page_icon, " + "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " + "created_at, updated_at FROM pages " + "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " + "ORDER BY created_at DESC", + (parent_id, ws_id), + ).fetchall() + + # Get workspace owner name for author display + ws_owner = conn.execute( + "SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?", + (ws_id,), + ).fetchone() + author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—" + + # Collect all page IDs to fetch tags in one query + all_ids = [r["id"] for r in rows] + tags_map = {} + favorited_ids = set() + if all_ids: + placeholders = ",".join("?" for _ in all_ids) + tag_rows = conn.execute( + f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt " + f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})", + all_ids, + ).fetchall() + for tr in tag_rows: + tags_map.setdefault(tr["page_id"], []).append({ + "id": tr["id"], "name": tr["name"], "color": tr["color"], + }) + if uid is not None: + fav_rows = conn.execute( + f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})", + [uid, *all_ids], + ).fetchall() + favorited_ids = {fr["page_id"] for fr in fav_rows} + + tree = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + children = _build_tree_children(conn, r["id"], ws_id, uid) + + # Compute size + size = 0 + if r["content_format"] == "file": + import json as _json + try: + meta = _json.loads(r["content"]) + size = meta.get("size", 0) + except Exception: + size = len(r["content"] or "") + else: + size = len(r["content"] or "") + + tree.append({ + "id": r["id"], + "name": r["title"] or "Untitled", + "type": "folder" if is_folder else "page", + "is_folder": is_folder, + "content_format": r["content_format"] if not is_folder else None, + "page_icon": r["page_icon"] or "", + "children": children, + "has_children": len(children) > 0, + "child_count": len(children), + "size": size, + "size_display": _format_size(size), + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "author": author, + "tags": tags_map.get(r["id"], []), + "is_shared": bool(r["is_shared"]), + "favorited": r["id"] in favorited_ids, + }) + return tree + + + + + + +def _format_size(size_bytes: int) -> str: + """Human-readable file size.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + + + + + +def _build_breadcrumb(conn, folder_id: int) -> list: + """Build breadcrumb trail from root to folder_id.""" + breadcrumb = [] + current = folder_id + seen = set() + while current and current not in seen: + seen.add(current) + row = conn.execute( + "SELECT id, title, parent_id, parent_section FROM pages WHERE id=?", + (current,), + ).fetchone() + if row: + breadcrumb.insert(0, { + "id": row["id"], + "name": row["title"] or "Untitled", + "is_folder": row["parent_section"] == "Workspace", + }) + current = row["parent_id"] + else: + break + return breadcrumb + + + + + + +def _nav_breadcrumb(conn, page_id: int) -> list: + """Build a Notion-style breadcrumb chain (root -> page) for the header. + + Returns a list of dicts: {id, label, url, icon, menu}. The last item is the + current page (url = None). Every item has ``menu: True`` so the header can + open a sibling-navigation dropdown for it. + """ + from app.routers.board import _file_icon + chain = [] + current = page_id + seen = set() + while current and current not in seen: + seen.add(current) + row = conn.execute( + "SELECT id, title, parent_id, parent_section, content_format " + "FROM pages WHERE id=? AND deleted_at IS NULL", + (current,), + ).fetchone() + if not row: + break + is_folder = row["parent_section"] == "Workspace" + title = row["title"] or "Untitled" + chain.insert(0, { + "id": row["id"], + "label": title, + "url": None, + "icon": "folder" if is_folder else _file_icon(title, row["content_format"]), + "menu": True, + }) + current = row["parent_id"] + # All items except the current page are navigable links. + for i, item in enumerate(chain): + if i < len(chain) - 1: + item["url"] = f"/pages/{item['id']}" + return chain + + + + + + +def _get_active_workspace(request: Request, user_id: int = None) -> dict | None: + """Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None.""" + ws_id = request.cookies.get(WORKSPACE_COOKIE) + if ws_id: + try: + with get_conn() as conn: + ws = conn.execute("SELECT * FROM workspaces WHERE id=?", + (int(ws_id),)).fetchone() + if ws: + ws_dict = dict(ws) + # Verify ownership — only return if it belongs to the current user + if user_id is None or ws_dict.get("owner_id") == user_id: + return ws_dict + except (ValueError, Exception): + pass + # Fallback: first workspace owned by this user + if user_id: + with get_conn() as conn: + ws = conn.execute( + "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", + (user_id,) + ).fetchone() + if ws: + return dict(ws) + return None + + + + + + +def _sanitize_id(block_id: str) -> str: + """Sanitize a block id for use as an HTML anchor (only alnum kept).""" + if not block_id: + return "" + return "".join(ch for ch in str(block_id) if ch.isalnum()) + + + + + + +def _render_blocks_public(blocks: list, titles: dict | None = None) -> str: + """Render FlowDeck blocks as plain HTML for public pages. + + v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns + ``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links. + """ + html_parts = [] + + def _wiki(c: str) -> str: + if titles and ("[[fdpage:" in c or "[[fddate:" in c): + from app.services.wiki_links import resolve_tokens_html + return resolve_tokens_html(c, titles) + return c + + for b in blocks: + t = b.get("type", "paragraph") + c = _wiki(b.get("content", "") or "") + if t == "heading_1": + html_parts.append(f'

    {c}

    ') + elif t == "heading_2": + html_parts.append(f'

    {c}

    ') + elif t == "heading_3": + html_parts.append(f'

    {c}

    ') + elif t == "heading_4": + html_parts.append(f'

    {c}

    ') + elif t == "bulleted_list": + html_parts.append(f'
  • {c}
  • ') + elif t == "numbered_list": + html_parts.append(f'
  • {c}
  • ') + elif t == "to_do": + checked = "checked" if b.get("checked") else "" + todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else "" + html_parts.append( + f'
    ' + f'' + f'{c}' + f'
    ' + ) + elif t == "toggle": + children_html = "" + if b.get("children"): + children_html = '
    ' + children_html += _render_blocks_public(b["children"], titles) + children_html += "
    " + html_parts.append( + f'
    {c}{children_html}
    ' + ) + elif t == "quote": + html_parts.append( + f'
    {c}
    ' + ) + elif t == "table_of_contents": + toc = [ + x for x in blocks + if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip() + ] + if toc: + items = [] + for h in toc: + lvl = int(h["type"].split("_")[-1]) + items.append( + f'
    ' + f'{h.get("content","")}
    ' + ) + html_parts.append( + '
    ' + '
    On this page
    ' + + "".join(items) + "
    " + ) + elif t == "math": + tex = c.replace("&", "&").replace("<", "<").replace(">", ">") + html_parts.append( + f'
    ' + ) + elif t == "columns": + cols_html = "" + for child in b.get("children") or []: + cols_html += ( + '
    ' + + _render_blocks_public([child], titles) + "
    " + ) + html_parts.append( + f'
    {cols_html}
    ' + ) + elif t == "callout": + icon = b.get("icon", "💡") + bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)") + html_parts.append( + f'
    ' + f'{icon}' + f'{c}
    ' + ) + elif t == "code": + lang = b.get("language", "") + lang_label = f"
    {lang}
    " if lang else "" + html_parts.append( + f'
    '
    +                f'{lang_label}'
    +                f'{c}
    ' + ) + elif t == "divider": + html_parts.append('
    ') + elif t == "image": + src = b.get("src", "") + alt = b.get("alt", "") + html_parts.append( + f'
    ' + f'{alt}' + f'
    ' + ) + elif t == "video": + src = b.get("src", "") + if src: + html_parts.append( + f'' + ) + elif t == "audio": + src = b.get("src", "") + if src: + html_parts.append( + f'' + ) + elif t == "bookmark": + url = b.get("url") or b.get("src") or "" + title = b.get("title") or url + desc = b.get("description") or "" + img = b.get("image") or "" + site = b.get("site_name") or "" + img_html = ( + f'' if img else "" + ) + desc_html = f'
    {desc}
    ' if desc else "" + site_html = f'
    {site}
    ' if site else "" + html_parts.append( + f'' + f'
    ' + f'
    {title}
    ' + f'{desc_html}{site_html}
    {img_html}
    ' + ) + elif t == "embed": + url = b.get("src", "") + emb = b.get("embed_type") or "" + if emb in ("inline_dbs", "collection"): + html_parts.append('
    [Embedded content]
    ') + elif emb == "download": + html_parts.append( + f'⬇ {b.get("file_name") or "Download"}' + ) + elif emb == "pdf" and url: + html_parts.append( + f'' + ) + elif url: + from app.services.embeds import embed_src + src = b.get("embed_src") or embed_src(url) or url + height = b.get("height") or 520 + try: + height = int(height) + except (ValueError, TypeError): + height = 520 + html_parts.append( + f'
    ' + ) + elif t == "synced": + # v6.5.0: render synced block instances (resolved server-side). + if b.get("_synced_deleted"): + html_parts.append( + '
    ' + 'Deleted synced block
    ' + ) + else: + inner = b.get("_synced_content") + if not isinstance(inner, list) or not inner: + try: + import json as _sj + parsed = _sj.loads(b.get("content") or "[]") + inner = parsed if isinstance(parsed, list) else [] + except (ValueError, TypeError): + inner = [] + inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x + for x in inner] + if inner: + html_parts.append( + '
    ' + + _render_blocks_public(inner, titles) + '
    ' + ) + else: + html_parts.append(f'

    {c}

    ') + return "\n".join(html_parts) + + +# ═══════════ Library page actions API ═══════════ + + diff --git a/app/routers/dashboard/account_api.py b/app/routers/dashboard/account_api.py new file mode 100644 index 0000000..3f40034 --- /dev/null +++ b/app/routers/dashboard/account_api.py @@ -0,0 +1,77 @@ +"""FlowDeck — Dashboard : account_api. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request + +from app.db import get_conn + +from ._common import _require_user_id + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +@router.put("/api/user/profile") +def update_profile(request: Request, body: dict = Body(default={})): + full_name = body.get("full_name", "").strip() + uid = _require_user_id(request) + with get_conn() as conn: + conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.put("/api/user/password") +def update_password(request: Request, body: dict = Body(default={})): + from app.password_utils import hash_password, verify_password + password = body.get("password", "").strip() + if len(password) < 6: + return {"error": "Password must be at least 6 characters"} + uid = _require_user_id(request) + # A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp). + current = body.get("current_password", "") + with get_conn() as conn: + row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone() + if not row or not verify_password(current, row["password_hash"]): + raise HTTPException(403, "Current password is incorrect") + conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/user/token") +def generate_token(request: Request): + import secrets + uid = _require_user_id(request) + token = secrets.token_hex(32) + with get_conn() as conn: + conn.execute( + "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + (uid, token), + ) + conn.commit() + return {"token": f"fd_{token}"} + + + + +@router.delete("/api/user/forge/{provider}") +def disconnect_forge(request: Request, provider: str): + uid = _require_user_id(request) + with get_conn() as conn: + conn.execute( + "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) + ) + conn.commit() + return {"status": "ok"} diff --git a/app/routers/dashboard/account_settings.py b/app/routers/dashboard/account_settings.py new file mode 100644 index 0000000..ce3f518 --- /dev/null +++ b/app/routers/dashboard/account_settings.py @@ -0,0 +1,439 @@ +"""FlowDeck — Dashboard : settings. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.db import get_conn + +from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +# ═══════════ Settings Page ═══════════ + +@router.get("/settings", response_class=HTMLResponse) +def app_settings_page(request: Request): + """Settings & configuration page.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return RedirectResponse("/auth/login?provider=local", status_code=302) + ctx = {**sidebar, "user": user} + # Pass active workspace for breadcrumb nav menu + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ctx["nav_workspace_id"] = ws["id"] if ws else 0 + template = env.get_template("settings.html") + return template.render(**ctx) + + + + +@router.post("/api/settings/avatar") +async def upload_avatar(request: Request): + """Upload a user avatar image.""" + import os + import uuid + from pathlib import Path + form = await request.form() + file = form.get("file") + if not file: + return {"error": "No file"}, 400 + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return {"error": "Not authenticated"}, 401 + # Save to data/avatars + avatars_dir = Path("/data/avatars") + avatars_dir.mkdir(parents=True, exist_ok=True) + ext = os.path.splitext(file.filename)[1] or ".png" + filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}" + filepath = avatars_dir / filename + content = await file.read() + filepath.write_bytes(content) + # Update user avatar_url + avatar_url = f"/api/settings/avatar/{filename}" + with get_conn() as conn: + conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"])) + conn.commit() + return {"avatar_url": avatar_url} + + + + +@router.get("/api/settings/avatar/{filename:path}") +def serve_avatar_file(filename: str): + """Serve an uploaded avatar image file.""" + from pathlib import Path + + from fastapi.responses import FileResponse + # A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette + # accepte les `/`, donc `..%2f` ressortirait du dossier avatars. + base_dir = Path("/data/avatars").resolve() + filepath = (base_dir / filename).resolve() + try: + filepath.relative_to(base_dir) + except ValueError: + return JSONResponse({"error": "Path traversal denied"}, status_code=403) + if not filepath.is_file(): + return JSONResponse({"error": "Not found"}, status_code=404) + return FileResponse(filepath) + + + + +@router.get("/api/avatar/{user_id:int}") +def get_avatar(user_id: int): + """Redirect to the user's avatar.""" + with get_conn() as conn: + row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() + if row and row["avatar_url"]: + return RedirectResponse(row["avatar_url"], status_code=302) + return JSONResponse({"error": "No avatar"}, status_code=404) + + + + +@router.post("/api/settings/avatar-color") +def set_avatar_color(request: Request, body: dict = Body(default={})): + """Set the user's avatar background color.""" + color = body.get("color", "#3A3A3A") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return {"error": "Not authenticated"}, 401 + with get_conn() as conn: + conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"])) + conn.commit() + return {"status": "ok", "color": color} + + +# ═══════════ Tag Management API (per-user) ═══════════ + + + + +# ═══════════ Tag Management API (per-user) ═══════════ + +@router.post("/api/settings/tags") +def create_tag_global(request: Request, body: dict = Body(default={})): + """Create a tag for the current user.""" + tag_name = body.get("name", "").strip().lower() + color = body.get("color", "#787774") + uid = _get_user_id(request) + if not tag_name or not uid: + return {"error": "Tag name required"}, 400 + with get_conn() as conn: + tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() + if tag: + conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"])) + conn.commit() + return {"tag": {"id": tag["id"], "name": tag_name, "color": color}} + cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid)) + conn.commit() + return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}} + + + + +@router.put("/api/settings/tags/{tag_id:int}") +def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})): + """Update a tag (name or color) — only if owned by user.""" + uid = _get_user_id(request) + with get_conn() as conn: + if "name" in body: + conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid)) + if "color" in body: + conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.delete("/api/settings/tags/{tag_id:int}") +def delete_tag_global(tag_id: int, request: Request): + """Delete a tag — only if owned by user.""" + uid = _get_user_id(request) + with get_conn() as conn: + conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,)) + conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.get("/api/settings/tags/all") +def list_all_tags_global(request: Request): + """List current user's tags with counts.""" + uid = _get_user_id(request) + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count " + "FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id " + "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", + (uid,), + ).fetchall() + return JSONResponse( + {"tags": [dict(r) for r in rows]}, + headers={"Cache-Control": "no-store"}, + ) + + +# ═══════════ Workspace Tags API ═══════════ + + + + +# ═══════════ Workspace Tags API ═══════════ + +@router.get("/api/local-workspace/tags") +def list_tags(request: Request): + """List ALL user tags with counts scoped to the active workspace.""" + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = ws["id"] if ws else None + uid = _get_user_id(request) + if not ws_id: + return {"tags": []} + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color, " + "(SELECT COUNT(*) FROM page_tags pt " + " JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " + " WHERE pt.tag_id = t.id) as count " + "FROM tags t WHERE t.user_id = ? ORDER BY t.name", + (ws_id, uid), + ).fetchall() + return JSONResponse( + {"tags": [dict(r) for r in rows]}, + headers={"Cache-Control": "no-store"}, + ) + + + + +@router.get("/api/local-workspace/items/{item_id:int}/tags") +def get_item_tags(item_id: int): + """Get tags for a specific item.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color FROM tags t " + "JOIN page_tags pt ON pt.tag_id = t.id " + "WHERE pt.page_id = ? ORDER BY t.name", + (item_id,), + ).fetchall() + return {"tags": [dict(r) for r in rows]} + + + + +@router.post("/api/local-workspace/items/{item_id:int}/tags") +def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})): + """Add a tag to an item (creates tag if new, scoped to user).""" + tag_name = body.get("name", "").strip().lower() + tag_color = body.get("color", "#787774") + uid = _get_user_id(request) + if not tag_name: + return {"error": "Tag name required"}, 400 + + with get_conn() as conn: + # Get or create tag (per user) + tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() + if not tag: + cursor = conn.execute( + "INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid) + ) + conn.commit() + tag_id = cursor.lastrowid + tag = {"id": tag_id, "name": tag_name, "color": tag_color} + else: + tag_id = tag["id"] + + # Link tag to page (ignore duplicate) + try: + conn.execute( + "INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", + (item_id, tag_id), + ) + conn.commit() + except Exception: + logger.exception("add_item_tag") + + return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} + + + + +@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") +def remove_item_tag(item_id: int, tag_id: int): + """Remove a tag from an item.""" + with get_conn() as conn: + conn.execute( + "DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?", + (item_id, tag_id), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.get("/api/local-workspace/tags/search") +def search_by_tags(request: Request, tags: str = ""): + """Search items by tags (comma-separated).""" + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = ws["id"] if ws else None + if not ws_id: + return {"items": []} + tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()] + if not tag_names: + return {"items": []} + with get_conn() as conn: + placeholders = ",".join("?" for _ in tag_names) + rows = conn.execute( + f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, " + f"p.content, p.created_at, p.updated_at " + f"FROM pages p " + f"JOIN page_tags pt ON pt.page_id = p.id " + f"JOIN tags t ON t.id = pt.tag_id " + f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL " + f"ORDER BY p.updated_at DESC", + tag_names + [ws_id], + ).fetchall() + items = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + size = len(r["content"] or "") + items.append({ + "id": r["id"], + "name": r["title"] or "Untitled", + "is_folder": is_folder, + "content_format": r["content_format"], + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "size": size, + "size_display": _format_size(size), + }) + return {"items": items} + + +# ── Account update ── + + + + +# ── Account update ── +@router.put("/api/settings/account") +def update_account(request: Request, body: dict = Body(default={})): + """Update current user's profile: full_name, login, email, password.""" + from app.password_utils import hash_password + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + with get_conn() as conn: + uid = user["id"] + if "full_name" in body: + conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid)) + if "login" in body: + new_login = body["login"].strip() + if new_login and new_login != user.get("login"): + existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone() + if existing: + return JSONResponse({"error": "Username already taken"}, status_code=409) + conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid)) + if "email" in body: + conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid)) + if "password" in body and body["password"].strip(): + pw = body["password"].strip() + if len(pw) < 6: + return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) + conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid)) + conn.commit() + row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + user_data = dict(row) + # Refresh session cookie with updated data (keeps the same session id) + cookie = request.cookies.get("flowdeck_session", "") + new_session = SessionManager.refresh_session(cookie, user_data, request) + response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}}) + response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/") + return response + + +# ═══════════ Sidebar Refresh API ═══════════ + + + + +# ═══════════ Sidebar Refresh API ═══════════ + +@router.get("/api/sidebar/workspace-tree") +def sidebar_workspace_tree(request: Request): + """Return the sidebar workspace tree as HTML fragment. + + Called by appState().refreshSidebarTree() after CRUD operations + in the main content area to keep the sidebar in sync. + """ + from app.routers.board import _load_workspace_pages + from app.templating import ENV + + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return HTMLResponse("") + + ws_cookie = request.cookies.get("flowdeck_workspace", "") + if not ws_cookie: + return HTMLResponse('') + + # Gitea workspace — no server-side tree, loaded client-side + if ws_cookie.startswith("gitea:"): + return HTMLResponse('') + + try: + ws_id = int(ws_cookie) + with get_conn() as conn: + # Verify workspace belongs to user + row = conn.execute( + "SELECT id FROM workspaces WHERE id=? AND owner_id=?", + (ws_id, user["id"]) + ).fetchone() + if not row: + return HTMLResponse( + '' + ) + + pages = _load_workspace_pages(ws_cookie) + if not pages: + return HTMLResponse( + '' + ) + + # Render the tree using the extracted macro + env = ENV + template = env.from_string( + "{% from '_workspace_tree_macro.html' import render_workspace_tree %}" + "{{ render_workspace_tree(pages) }}" + ) + html = template.render(pages=pages) + return HTMLResponse(html) + except (ValueError, Exception) as e: + logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True) + return HTMLResponse( + '' + ) + + +# ═══════════ Public Published Page ═══════════ diff --git a/app/routers/dashboard/local_workspace.py b/app/routers/dashboard/local_workspace.py new file mode 100644 index 0000000..a725031 --- /dev/null +++ b/app/routers/dashboard/local_workspace.py @@ -0,0 +1,559 @@ +"""FlowDeck — Dashboard : local_workspace. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging +from datetime import UTC + +from fastapi import APIRouter, Body, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.config import settings +from app.db import get_conn + +from ._common import ( + _build_breadcrumb, + _build_tree_children, + _file_page_disk_path, + _get_active_workspace, + _get_user_id, + _require_page_view, + _require_user_id, + _sidebar_data, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +# ═══════════ Local Workspace (file/folder CRUD) ═══════════ + +@router.get("/local-workspace", response_class=HTMLResponse) +def local_workspace_page(request: Request, folder: int = None): + """Local workspace page with file/folder tree. + + If ?folder=ID is provided, shows that folder's contents with breadcrumb. + """ + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return RedirectResponse("/auth/login?provider=local", status_code=302) + + ws = _get_active_workspace(request, user_id=user["id"]) + ws_id = ws["id"] if ws else None + + # If no workspace exists for this user, redirect to workspaces page + if not ws_id: + return RedirectResponse("/workspaces", status_code=302) + + # Build breadcrumb if navigating into a folder + breadcrumb = [] + current_folder_id = folder + if folder and ws_id: + with get_conn() as conn: + breadcrumb = _build_breadcrumb(conn, folder) + + ctx = { + **sidebar, + "user": user, + "workspace_name": sidebar.get("active_ws_name", "My Workspace"), + "current_folder_id": current_folder_id or 0, + "workspace_id": ws_id or 0, + "nav_workspace_id": ws_id or 0, + "breadcrumb": breadcrumb, + "breadcrumbs": breadcrumb, + } + template = env.get_template("local_workspace.html") + return HTMLResponse( + content=template.render(**ctx), + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + ) + + + + +@router.get("/api/local-workspace/tree") +def local_workspace_tree(request: Request, folder: int = None): + """Return the file/folder tree filtered by active workspace. + + If ?folder=ID is provided, returns only that folder's children. + Otherwise returns the full recursive tree from root. + """ + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = ws["id"] if ws else None + if not ws_id: + return {"tree": [], "breadcrumb": []} + uid = _get_user_id(request) + + with get_conn() as conn: + if folder: + # Show only this folder's children + build breadcrumb + children = _build_tree_children(conn, folder, ws_id, uid) + breadcrumb = _build_breadcrumb(conn, folder) + return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder} + else: + # Full tree from root + roots = _build_tree_children(conn, None, ws_id, uid) + return {"tree": roots, "breadcrumb": [], "current_folder": None} + + + + +@router.get("/api/local-workspace/page-content/{page_id:int}") +def get_page_content(page_id: int): + """Return the raw content of a page (for preview).""" + with get_conn() as conn: + row = conn.execute( + "SELECT content, content_format FROM pages WHERE id=?", (page_id,) + ).fetchone() + if not row: + return JSONResponse({"error": "Not found"}, status_code=404) + fmt = row["content_format"] + if fmt == "file": + return JSONResponse({"content": "(uploaded file)", "format": fmt}) + # v6.5.0: resolve synced blocks server-side (fresh content on read). + from app.services.synced_blocks import resolve_content_json + return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt} + + + + +@router.get("/api/pages/{page_id}/download") +def download_page_file(request: Request, page_id: int): + """Download the original uploaded file of a ``file`` page (attachment).""" + _require_page_view(request, page_id) + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, content, content_format FROM pages " + "WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not row: + return JSONResponse({"error": "Not found"}, status_code=404) + resolved = _file_page_disk_path(dict(row)) + if not resolved: + return JSONResponse({"error": "No downloadable file"}, status_code=404) + full, filename, mime, _size = resolved + from fastapi.responses import FileResponse + return FileResponse( + str(full), media_type=mime or "application/octet-stream", + filename=filename, content_disposition_type="attachment", + ) + + + + +@router.get("/api/pages/{page_id}/file-content") +def page_file_content(request: Request, page_id: int): + """Return the textual content of a ``file`` page (for copy to clipboard). + + Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is + only meaningful for plain-text / code / markdown files. + """ + from app.services.export import _file_text + + _require_page_view(request, page_id) + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, content, content_format FROM pages " + "WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not row: + return JSONResponse({"error": "Not found"}, status_code=404) + page = dict(row) + text = _file_text(page) + if text is None: + return JSONResponse( + {"ok": False, "error": "Not a textual file", "name": page.get("title", "")}, + status_code=415, + ) + return {"ok": True, "name": page.get("title") or "File", "content": text} + + + + +@router.get("/api/local-workspace/breadcrumb") +def local_workspace_breadcrumb(request: Request, folder: int): + """Return breadcrumb trail for a folder.""" + with get_conn() as conn: + breadcrumb = _build_breadcrumb(conn, folder) + return {"breadcrumb": breadcrumb} + + + + +@router.get("/api/nav/menu") +def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None): + """Return the pages at one level for the header breadcrumb navigation menu. + + If ``parent_id`` is given, returns that page's children; otherwise the + workspace's root pages. Each item includes ``has_children`` so the frontend + can render an expandable sub-menu. + """ + from app.routers.board import _file_icon + ws_id = workspace_id + if not ws_id: + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = ws["id"] if ws else None + if not ws_id: + return {"items": []} + with get_conn() as conn: + if parent_id: + rows = conn.execute( + "SELECT id, title, parent_section, content_format FROM pages " + "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " + "ORDER BY sort_order ASC, created_at DESC", + (parent_id, ws_id), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, title, parent_section, content_format FROM pages " + "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " + "ORDER BY sort_order ASC, created_at DESC", + (ws_id,), + ).fetchall() + ids = [r["id"] for r in rows] + child_counts = {} + if ids: + placeholders = ",".join("?" for _ in ids) + cc_rows = conn.execute( + f"SELECT parent_id, COUNT(*) AS c FROM pages " + f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL " + f"GROUP BY parent_id", + ids, + ).fetchall() + for cr in cc_rows: + child_counts[cr["parent_id"]] = cr["c"] + items = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + title = r["title"] or "Untitled" + items.append({ + "id": r["id"], + "name": title, + "icon": "folder" if is_folder else _file_icon(title, r["content_format"]), + "has_children": child_counts.get(r["id"], 0) > 0, + "url": f"/pages/{r['id']}", + }) + return {"items": items} + + + + +@router.post("/api/local-workspace/items") +def create_local_workspace_item(request: Request, body: dict = Body(default={})): + """Create a new file in the active workspace.""" + name = (body.get("name") or "").strip() or "Untitled" + item_type = body.get("type", "page") + parent_id = body.get("parent_id") + explicit_ws_id = body.get("workspace_id") + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = explicit_ws_id or (ws["id"] if ws else None) + ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else "" + section = 'Workspace' if item_type == 'folder' else 'Private' + with get_conn() as conn: + cursor = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) " + "VALUES (?, ?, ?, '', 'blocks', ?, ?)", + (ws_key, ws_id, name, section, parent_id) + ) + conn.commit() + pid = cursor.lastrowid + return {"id": pid, "name": name, "type": item_type} + + + + +@router.put("/api/local-workspace/items/{item_id:int}") +def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})): + """Rename a file.""" + name = body.get("name", "Untitled").strip() + with get_conn() as conn: + conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id)) + conn.commit() + return {"status": "ok"} + + + + +@router.delete("/api/local-workspace/items/{item_id:int}") +def delete_local_workspace_item(request: Request, item_id: int): + """Soft-delete a file/folder (sets deleted_at).""" + from datetime import datetime + with get_conn() as conn: + conn.execute( + "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL", + (datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/local-workspace/items/{item_id:int}/restore") +def restore_local_workspace_item(request: Request, item_id: int): + """Restore a soft-deleted file/folder.""" + with get_conn() as conn: + conn.execute( + "UPDATE pages SET deleted_at=NULL WHERE id=?", + (item_id,), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.get("/api/files/{ws_id:int}/{filename:path}") +def serve_uploaded_file(ws_id: int, filename: str): + """Serve an uploaded file from disk.""" + import mimetypes + from pathlib import Path + root = Path(settings.data_dir) + base_dir = (root / f"uploads/workspace_{ws_id}").resolve() + fp = (base_dir / filename).resolve() + try: + fp.relative_to(base_dir) + except ValueError: + return JSONResponse({"error": "Path traversal denied"}, status_code=403) + if not fp.exists(): + return JSONResponse({"error": "File not found"}, status_code=404) + mime, _ = mimetypes.guess_type(str(fp)) + content = fp.read_bytes() + from fastapi.responses import Response + return Response(content=content, media_type=mime or "application/octet-stream") + + + + +@router.put("/api/local-workspace/items/{item_id:int}/move") +def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})): + """Move an item to a new parent (drag & drop).""" + new_parent_id = body.get("parent_id") # None = move to root + with get_conn() as conn: + conn.execute( + "UPDATE pages SET parent_id=? WHERE id=?", + (new_parent_id, item_id), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/local-workspace/upload") +async def upload_local_workspace_file(request: Request): + """Upload one or more files via drag-and-drop. + + Accepts multipart form with 'files' field (one or multiple files). + Optional: 'parent_id' to place files in a specific folder. + Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records. + """ + import json + from pathlib import Path + + uid = _require_user_id(request) # A22 : pas d'upload anonyme + ws = _get_active_workspace(request, user_id=uid) + ws_id = ws["id"] if ws else None + if not ws_id: + return JSONResponse({"error": "No active workspace"}, status_code=400) + + try: + form = await request.form() + except Exception: + return JSONResponse({"error": "Invalid form data"}, status_code=400) + + parent_id_raw = form.get("parent_id") + parent_id = int(parent_id_raw) if parent_id_raw else None + files = form.getlist("files") + + if not files: + return JSONResponse({"error": "No files provided"}, status_code=400) + + + from app.middleware.security import validate_upload + + data_root = Path(settings.data_dir) + upload_dir = data_root / f"uploads/workspace_{ws_id}" + upload_dir.mkdir(parents=True, exist_ok=True) + + results = [] + with get_conn() as conn: + for f in files: + filename = f.filename or "untitled" + # Sanitize filename: only keep basename, prevent path traversal + safe_name = Path(filename).name + if not safe_name: + safe_name = "untitled" + + # Unique filename on disk + file_path = upload_dir / safe_name + stem, suffix = file_path.stem, file_path.suffix + counter = 1 + while file_path.exists(): + file_path = upload_dir / f"{stem} ({counter}){suffix}" + counter += 1 + + content = await f.read() + err = validate_upload(safe_name, len(content)) # A22 : taille + extension + if err: + results.append({"name": safe_name, "error": err}) + continue + file_path.write_bytes(content) + + # Determine if this is a folder marker or actual file + rel_path = str(file_path.relative_to(data_root)) + size = len(content) + mime = f.content_type or "application/octet-stream" + + cursor = conn.execute( + """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) + VALUES ('', ?, ?, ?, 'file', 'Private', ?)""", + (ws_id, file_path.name, + json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}), + parent_id), + ) + results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size}) + + conn.commit() + + return {"status": "ok", "items": results} + + + + +@router.post("/api/local-workspace/upload-folder") +async def upload_local_workspace_folder(request: Request): + """Handle recursive folder upload. + + Frontend walks the directory tree with webkitGetAsEntry and sends: + - 'structure': JSON array of {path: str, type: 'folder'|'file'} + - 'files': multipart files (one per file in the structure) + - 'parent_id': target folder (optional) + + Creates folders first, then uploads files into their respective folders. + """ + import json + from pathlib import Path + + uid = _require_user_id(request) # A22 : pas d'upload anonyme + ws = _get_active_workspace(request, user_id=uid) + ws_id = ws["id"] if ws else None + if not ws_id: + return JSONResponse({"error": "No active workspace"}, status_code=400) + + try: + form = await request.form() + except Exception: + return JSONResponse({"error": "Invalid form data"}, status_code=400) + + parent_id_raw = form.get("parent_id") + root_parent_id = int(parent_id_raw) if parent_id_raw else None + structure_raw = form.get("structure") + + if not structure_raw: + return JSONResponse({"error": "No structure provided"}, status_code=400) + + try: + structure = json.loads(structure_raw) + except json.JSONDecodeError: + return JSONResponse({"error": "Invalid structure JSON"}, status_code=400) + + + from app.middleware.security import validate_upload + + data_root = Path(settings.data_dir) + upload_dir = data_root / f"uploads/workspace_{ws_id}" + upload_dir.mkdir(parents=True, exist_ok=True) + + results = [] + created_folders = {} # relative_path -> db_id + + with get_conn() as conn: + # Phase 1: Create all folders + for item in structure: + if item.get("type") != "folder": + continue + path_parts = item["path"].strip("/").split("/") + folder_name = path_parts[-1] + # Determine parent: parent of this folder in the tree + if len(path_parts) == 1: + actual_parent = root_parent_id + else: + parent_path = "/".join(path_parts[:-1]) + actual_parent = created_folders.get(parent_path) + + cursor = conn.execute( + """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) + VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""", + (ws_id, folder_name, actual_parent), + ) + fid = cursor.lastrowid + created_folders[item["path"].strip("/")] = fid + results.append({"id": fid, "name": folder_name, "type": "folder"}) + + # Phase 2: Upload files into their respective folders + for item in structure: + if item.get("type") != "file": + continue + path_parts = item["path"].strip("/").split("/") + file_name = path_parts[-1] + if len(path_parts) == 1: + file_parent = root_parent_id + else: + parent_path = "/".join(path_parts[:-1]) + file_parent = created_folders.get(parent_path) + + # Find the matching file in multipart data + matched = None + for f in form.getlist("files"): + if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)): + matched = f + break + if not matched: + continue + + safe_name = Path(file_name).name + file_path = upload_dir / safe_name + stem, suffix = file_path.stem, file_path.suffix + counter = 1 + while file_path.exists(): + file_path = upload_dir / f"{stem} ({counter}){suffix}" + counter += 1 + + content = await matched.read() + err = validate_upload(safe_name, len(content)) # A22 : taille + extension + if err: + results.append({"name": safe_name, "error": err}) + continue + file_path.write_bytes(content) + + rel_path = str(file_path.relative_to(data_root)) + cursor = conn.execute( + """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) + VALUES ('', ?, ?, ?, 'file', 'Private', ?)""", + (ws_id, file_path.name, + json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}), + file_parent), + ) + results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)}) + + conn.commit() + + return {"status": "ok", "items": results} + + +# ═══════════ Workspaces CRUD ═══════════ diff --git a/app/routers/dashboard/pages_api.py b/app/routers/dashboard/pages_api.py new file mode 100644 index 0000000..c5732b0 --- /dev/null +++ b/app/routers/dashboard/pages_api.py @@ -0,0 +1,240 @@ +"""FlowDeck — Dashboard : pages_api. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Request +from fastapi.responses import JSONResponse + +from app.auth.session import SessionManager +from app.db import get_conn + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +# ═══════════ Library page actions API ═══════════ + +@router.get("/api/pages/{page_id:int}/content") +def api_page_content(page_id: int): + """Get page content for side peek preview.""" + with get_conn() as conn: + row = conn.execute( + "SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not row: + return JSONResponse({"error": "Not found"}, status_code=404) + # v6.5.0: resolve synced blocks server-side (fresh content on read). + from app.services.synced_blocks import resolve_content_json + return { + "title": row["title"], + "content": resolve_content_json(row["content"], row["content_format"]), + "format": row["content_format"] or "blocks", + } + + + + +@router.put("/api/pages/{page_id:int}/rename") +def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})): + """Inline rename a page title.""" + title = (body.get("title") or "").strip() + if not title: + return JSONResponse({"error": "Title required"}, status_code=400) + with get_conn() as conn: + conn.execute( + "UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", + (title, page_id), + ) + # v6.5.0: renaming a database row's content page updates the row. + from app.services.row_pages import sync_page_title_to_row + sync_page_title_to_row(conn, page_id) + conn.commit() + return {"status": "ok", "title": title} + + + + +@router.post("/api/pages/{page_id:int}/trash") +def api_trash_page(page_id: int): + """Soft-delete a page (move to trash).""" + with get_conn() as conn: + conn.execute( + "UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?", + (page_id,), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/pages/{page_id:int}/convert-to-database") +def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})): + """Convert a page into a full-page database (Notion-style). + + Creates a collection linked to this page, adds the default 'Name' property, + and sets the page's content_format to 'collection'. + """ + import json as _json + db_name = (body.get("name") or "").strip() + + with get_conn() as conn: + page = conn.execute( + "SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not page: + return JSONResponse({"error": "Page not found"}, status_code=404) + + if not db_name: + db_name = page["title"] or "New Database" + + # Create the collection + cur = conn.execute( + """INSERT INTO collections + (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) + VALUES (?, '', '📋', '[]', 0, ?, ?)""", + (db_name, page_id, page["workspace_id"]), + ) + collection_id = cur.lastrowid + + # Create default "Name" property (text, position 0) + conn.execute( + """INSERT INTO collection_properties + (collection_id, name, prop_type, position, required, visible_in_views) + VALUES (?, 'Name', 'title', 0, 1, 1)""", + (collection_id,), + ) + + # Create default "Table" view + conn.execute( + """INSERT INTO collection_views + (collection_id, name, view_type, config_json, position) + VALUES (?, 'Table', 'table', ?, 0)""", + (collection_id, _json.dumps({"visible_properties": ["Name"]})), + ) + + # Update the page to be a database page + conn.execute( + "UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (collection_id, page_id), + ) + + conn.commit() + + return { + "status": "converted", + "collection_id": collection_id, + "name": db_name, + "view_url": f"/pages/{page_id}", + } + + + + +@router.get("/api/collections/{collection_id:int}/table-data") +def api_collection_table_data(collection_id: int): + """Get collection properties + pages for rendering the table view.""" + with get_conn() as conn: + coll = conn.execute( + "SELECT * FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not coll: + return JSONResponse({"error": "Collection not found"}, status_code=404) + + properties = [ + dict(r) for r in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + ] + + pages = [ + dict(r) for r in conn.execute( + "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + ] + + views = [ + dict(r) for r in conn.execute( + "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + ] + + return { + "collection": dict(coll), + "properties": properties, + "pages": pages, + "views": views, + } + + + + +@router.post("/api/collections/{collection_id:int}/pages") +def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})): + """Create a new page (row) in a collection.""" + import json as _json + title = body.get("title", "New page").strip() or "New page" + icon = body.get("icon", "file") + + with get_conn() as conn: + coll = conn.execute( + "SELECT id FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not coll: + return JSONResponse({"error": "Collection not found"}, status_code=404) + + # Get next position + max_pos = conn.execute( + "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", + (collection_id,), + ).fetchone()[0] + + # Load default property values from collection properties + props = [ + dict(r) for r in conn.execute( + "SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + ] + default_values = {} + for p in props: + if p["prop_type"] == "title": + default_values[str(p["id"])] = title + # Caller-provided values (e.g. board "add card in column X") win. + incoming = body.get("properties") or {} + if isinstance(incoming, dict): + default_values.update(incoming) + + from app.services.property_types import apply_auto_properties + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1} + apply_auto_properties(props, default_values, user, is_create=True) + + cur = conn.execute( + """INSERT INTO collection_pages + (collection_id, title, icon, cover_url, position, property_values_json) + VALUES (?, ?, ?, ?, ?, ?)""", + (collection_id, title, icon, body.get("cover_url", ""), max_pos, + _json.dumps(default_values)), + ) + page_id = cur.lastrowid + conn.commit() + + return { + "id": page_id, + "title": title, + "icon": icon, + "position": max_pos, + "property_values_json": default_values, + "status": "created", + } diff --git a/app/routers/dashboard/pages_html.py b/app/routers/dashboard/pages_html.py new file mode 100644 index 0000000..fc91d7d --- /dev/null +++ b/app/routers/dashboard/pages_html.py @@ -0,0 +1,485 @@ +"""FlowDeck — Dashboard : pages_html. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.db import get_conn + +from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +@router.get("/trash", response_class=HTMLResponse) +def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): + """Trash page — scoped to workspace if owner/repo provided.""" + from app.routers.board import _sidebar_data as board_sidebar + from app.templating import ENV + env = ENV + sidebar = board_sidebar(request, owner, repo) + with get_conn() as conn: + ws_key = f"{owner}/{repo}" if owner and repo else "" + # Pages are soft-deleted via parent_section='Trash' + if ws_key: + rows = conn.execute( + "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", + (ws_key,), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", + ).fetchall() + sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] + # Pass active workspace for breadcrumb nav menu + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + sidebar["nav_workspace_id"] = ws["id"] if ws else 0 + template = env.get_template("trash.html") + return template.render(**sidebar) + + + + +@router.get("/library", response_class=HTMLResponse) +def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): + """Library page — tabbed view (recents, favorites, shared, published, private, workspace). + + Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. + """ + from app.routers.board import _sidebar_data as board_sidebar + from app.templating import ENV + env = ENV + sidebar = board_sidebar(request, owner, repo) + # Pass active workspace for breadcrumb nav menu + ws_key_ws = f"{owner}/{repo}" if owner and repo else "" + if ws_key_ws: + with get_conn() as conn: + ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone() + sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0 + else: + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + sidebar["nav_workspace_id"] = ws["id"] if ws else 0 + + template = env.get_template("library.html") + sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0) + # Gitea workspace context for Repository tab + sidebar["is_gitea_workspace"] = bool(owner and repo) + sidebar["gitea_workspace_owner"] = owner + sidebar["gitea_workspace_repo"] = repo + return template.render(**sidebar) + + + + +@router.get("/pages/{page_id}", response_class=HTMLResponse) +def view_page_root(request: Request, page_id: int): + """Render a Markdown page at root level with workspace context — or file viewer. + ?embed=1 — minimal mode for side peek (editor only, no header).""" + embed = request.query_params.get("embed") == "1" + from app.routers.board import _sidebar_data as board_sidebar + from app.templating import ENV + env = ENV + with get_conn() as conn: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + return RedirectResponse("/workspaces", status_code=302) + page = dict(row) + # v6.5.0: synced blocks resolve server-side at read time. + from app.services.synced_blocks import resolve_content_json + page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) + + ws = page.get("workspace", "") + parts = ws.split("/") if "/" in ws else ["", ""] + owner, repo = parts[0], parts[1] if len(parts) > 1 else "" + sidebar = board_sidebar(request, owner, repo) + # Load sub-pages + with get_conn() as conn: + subs = conn.execute( + "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", + (page_id,), + ).fetchall() + fav = conn.execute( + "SELECT id FROM favorites WHERE user_id=? AND page_id=?", + (1, page_id), + ).fetchone() + + # Build page_data, including file metadata for uploaded files + _locked = bool(page.get("is_locked", 0)) + _locked_by = page.get("locked_by") if "locked_by" in page else None + _sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + _uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None + _can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid) + page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), + "is_locked": _locked, + "locked_by": _locked_by, + "can_edit": _can_edit, + "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, + "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} + + # For file pages, extract file metadata and add to page_data + if page.get("content_format") == "file": + import json as _json + try: + meta = _json.loads(page.get("content", "{}")) + except _json.JSONDecodeError: + meta = {} + file_path = meta.get("file_path", "").replace("\\", "/") + mime_type = meta.get("mime_type", "application/octet-stream") + file_size = meta.get("size", 0) + fp_parts = file_path.split("/") + ws_id = "" + for p in fp_parts: + if p.startswith("workspace_"): + ws_id = p.replace("workspace_", "") + break + filename = fp_parts[-1] if fp_parts else page.get("title", "File") + from urllib.parse import quote + safe_name = quote(filename, safe='') + file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else "" + page_data["file_url"] = file_url + page_data["file_mime"] = mime_type + page_data["file_size"] = file_size + page_data["file_name"] = filename + + # For collection (database) pages, load collection + properties + pages + collection_data = None + if page.get("content_format") == "collection" and page.get("collection_id"): + with get_conn() as conn: + col = conn.execute( + "SELECT * FROM collections WHERE id=?", (page["collection_id"],) + ).fetchone() + if col: + props = [ + dict(r) for r in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (page["collection_id"],), + ).fetchall() + ] + cpages = [ + dict(r) for r in conn.execute( + "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", + (page["collection_id"],), + ).fetchall() + ] + cviews = [ + dict(r) for r in conn.execute( + "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", + (page["collection_id"],), + ).fetchall() + ] + collection_data = { + "collection": dict(col), + "properties": props, + "pages": cpages, + "views": cviews, + } + page_data["collection_id"] = page["collection_id"] + + with get_conn() as conn: + nav_crumbs = _nav_breadcrumb(conn, page_id) + # dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27) + page_is_shared = ( + bool(page.get("is_shared", 0)) + or page.get("share_mode", "private") != "private" + or bool(page.get("published", 0)) + ) + ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], + "page_favorited": fav is not None, + "page_share_mode": page.get("share_mode", "private"), + "page_published": bool(page.get("published", 0)), + "page_is_shared": page_is_shared, + "page_data": page_data, + "collection_data": collection_data, + "breadcrumb_items": nav_crumbs, + "nav_workspace_id": page.get("workspace_id") or 0, + "nav_page_id": page_id, + "embed_mode": embed} + # A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON) + # au lieu des interpolations Jinja — une seule source, même calculs que le + # ctx ci-dessus. + from app.templating import ENV as _ENV27 + page_data.update( + updated_at=page.get("updated_at", ""), + created_at=page.get("created_at", ""), + user_id=_uid or 0, + is_shared=page_is_shared, + clip_icon=_ENV27.from_string( + "{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}" + ).render(), + ) + # Select template: collection pages use database table view + if page.get("content_format") == "collection" and not embed: + template = env.get_template("page_editor_collection.html") + else: + template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") + response = template.render(**ctx) + return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) + + + + +@router.get("/accounts", response_class=HTMLResponse) +def accounts_page(request: Request): + """Account management panel.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + with get_conn() as conn: + users = conn.execute( + "SELECT id, login, full_name, email, avatar_url, avatar_color, " + "is_admin, is_active, created_at FROM users ORDER BY created_at DESC" + ).fetchall() + ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} + template = env.get_template("accounts.html") + return template.render(**ctx) + + + + +@router.get("/help", response_class=HTMLResponse) +def help_page(request: Request): + """Comprehensive help & documentation page.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + # Render via a block-based template so content_html lands in {% block content %} + block_tpl = env.from_string( + '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' + ) + return HTMLResponse(block_tpl.render( + **sidebar, + request=request, + page_title="Help", + title_prefix="Help", + page_icon="❓", + content_html=""" +
    +
    +

    ❓ FlowDeck Help

    +

    Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.

    +
    + +
    +
    +

    🚀Getting Started

    +

    FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.

    +
      +
    • Create a workspace from the Workspaces page
    • +
    • Click 📄 New Page in the sidebar to start writing
    • +
    • Use Ctrl+N anywhere to create a page
    • +
    +
    + +
    +

    📝Pages & Editor

    +

    Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.

    +
      +
    • Type / for the slash command menu
    • +
    • Drag & drop pages in the sidebar to reorganize
    • +
    • Right-click for context menu (duplicate, rename, delete)
    • +
    +
    + +
    +

    {{ fd_icon("folder",16) }}Workspaces

    +

    Organize your work into separate workspaces. Each has its own pages and files.

    +
      +
    • Local Files stored on your server
    • +
    • Gitea Connect to browse & edit repos
    • +
    • GitHub Connect via Settings → Integrations
    • +
    +
    + +
    +

    🦎Gitea Integration

    +

    Connect your Gitea account to access repositories directly from FlowDeck.

    +
      +
    • Go to Settings → Integrations to connect
    • +
    • Browse repo file trees in the sidebar
    • +
    • Create & edit files with commit messages
    • +
    • Sync labels as tags
    • +
    +
    + +
    +

    🌐Sharing & Publishing

    +

    Share pages with collaborators or publish them to the web.

    +
      +
    • Click Share in the page editor top-right
    • +
    • Share with specific users or get a public link
    • +
    • Publish to make a page visible at /p/your-slug
    • +
    +
    + +
    +

    📚Library, Trash & Tasks

    +

    Find all your content in one place with powerful filtering.

    +
      +
    • Library — Tabs for Recents, Favorites, Shared, Published
    • +
    • Trash — Soft-deleted pages (30-day retention)
    • +
    • My Tasks — Aggregated tasks from all collections
    • +
    +
    + +
    +

    📶Offline & PWA

    +

    Install FlowDeck as an app and keep working without a connection.

    +
      +
    • Install — browser menu → Install app / Add to Home Screen
    • +
    • Edits made offline are queued locally and synced automatically
    • +
    • A ⟳ marker shows pages with pending changes
    • +
    +
    +
    + +
    +

    ⌨️ Keyboard Shortcuts

    +
    Ctrl+N
    Create new page
    +
    Ctrl+K
    Quick find / command palette
    +
    Ctrl+S
    Save current page
    +
    F2
    Rename selected item
    +
    Delete
    Move selected item to trash
    +
    Escape
    Close modal / cancel editing
    +
    Enter
    Open selected page
    +
    Ctrl+O
    New AI chat (in footer)
    +
    + +
    +

    🔐 Authentication

    +

    +FlowDeck supports three authentication methods:
    +Local Email + password — create an account on the login page.
    +Gitea OAuth Login with your Gitea account. Your repos appear as workspaces.
    +GitHub OAuth Login or link your GitHub account in Settings → Integrations.

    +Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user. +

    +

    +SSO Enterprise SSO (v6.7.0) — sign in with your organization account.
    +For administrators: open Settings → Admin → SSO / Enterprise and pick a provider:
    +• SAML 2.0 — paste the IdP Entity ID, SSO URL and signing certificate, then give the IdP this +/auth/saml/metadata link (it contains the SP Entity ID, ACS URL and certificate).
    +• OpenID Connect — paste the Issuer URL, Client ID and Client Secret (PKCE is used, scopes default to openid profile email).
    +• Provisioning — accounts are created automatically on first login, groups from the IdP map to workspace roles, +and SSO only disables local login (admins keep their local door). Every attempt is audited in +Settings → Admin → SSO / Enterprise (login history). +

    +
    + +
    +

    📶 Offline mode (PWA)

    +

    +FlowDeck is a Progressive Web App: pages you visited stay available offline and your +edits are saved locally, then synchronised when the connection returns.

    +Install: open your browser menu and choose Install app (Chrome/Edge) or +Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
    +Offline editing: while offline, the editor stores changes in the browser +(IndexedDB) and shows an offline banner with the number of pending changes. A +⟳ icon appears next to pages that have unsynced edits.
    +Reconnection: the queue is replayed automatically (and via Background Sync). +A spinner badge appears while syncing, followed by a confirmation toast.
    +Conflicts: if a page changed on the server, the latest edit wins and a notice is +shown. If a page was deleted server-side, your offline copy is recreated as an orphan +page. If a page with the same title already exists, the offline copy is renamed +“Title (copie offline)”. +

    +
    + +
    +

    🔌 API publique v2

    +

    +FlowDeck exposes a full REST API under /api/v2 for third-party integrations.
    +Auth: create a token in Settings → API tokens, then send it as +Authorization: Bearer <token>. Tokens carry scopes +read, write or admin (a higher scope implies the lower ones).
    +Features: CRUD on collections, pages, properties, views, comments, notifications, +favorites, tags, sharing, sprints and templates; pagination (?limit=&offset= + +X-Total-Count), filters (filter[prop]=value), sorting, full-text search +(/api/v2/search), idempotency (Idempotency-Key) and RFC 7807 error bodies.
    +Reference: interactive OpenAPI docs at /docs +(also /redoc, docs/openapi-v2.json). +

    +
    + +
    +

    💡 Tips

    +

    +• Toggle the sidebar with the « button in the top-left corner.
    +• Switch between workspaces using the dropdown menu in the sidebar header.
    +• The Private section appears when a remote workspace is active — files here stay local.
    +• Hover over any sidebar item to see action buttons (favorite, share, delete).
    +• Use Ctrl+Click or Shift+Click to multi-select items in the sidebar. +

    +
    + +
    """ + )) + + + + +@router.get("/accounts/settings", response_class=HTMLResponse) +def settings_page(request: Request): + """User settings page — profile, forges, tokens.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + from fastapi.responses import RedirectResponse + return RedirectResponse("/auth/login?provider=local", status_code=302) + # Check forge connections + gitea_connected = False + github_connected = False + if user.get("id"): + with get_conn() as conn: + tokens = conn.execute( + "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) + ).fetchall() + for t in tokens: + if t["provider"] == "gitea": + gitea_connected = True + elif t["provider"] == "github": + github_connected = True + ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected} + template = env.get_template("settings.html") + response = template.render(**ctx) + return HTMLResponse(content=response) + + +# ═══════════ User API endpoints ═══════════ diff --git a/app/routers/dashboard/public.py b/app/routers/dashboard/public.py new file mode 100644 index 0000000..3bae8c1 --- /dev/null +++ b/app/routers/dashboard/public.py @@ -0,0 +1,78 @@ +"""FlowDeck — Dashboard : public. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from app.db import get_conn + +from ._common import _render_blocks_public + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +# ═══════════ Public Published Page ═══════════ + + +@router.get("/p/{slug}", response_class=HTMLResponse) +def public_published_page(request: Request, slug: str): + """Serve a published page at /p/ — no auth required.""" + from app.templating import ENV + + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon " + "FROM pages WHERE publish_slug=? AND is_published=1", + (slug,), + ).fetchone() + + if not row: + return HTMLResponse( + """ +Not Found — FlowDeck +

    404

    """, + status_code=404, + ) + + page = dict(row) + env = ENV + + # Convert blocks to HTML for rendering + content_html = "" + if page.get("content_format") == "blocks" and page.get("content"): + import json as _json + try: + blocks = _json.loads(page["content"]) + from app.services.synced_blocks import resolve_synced_block + blocks = resolve_synced_block(blocks) + from app.db import get_conn as _gc + from app.services.wiki_links import token_labels + with _gc() as conn: + wiki_titles_map = token_labels(conn, page["content"]) + content_html = _render_blocks_public(blocks, wiki_titles_map) + except (_json.JSONDecodeError, Exception): + content_html = f"

    {page.get('content', '')}

    " + elif page.get("content"): + # Plain text / markdown + text = page["content"] + content_html = f"
    {text}
    " + + template = env.get_template("public_page.html") + return template.render( + title=page["title"] or "Untitled", + content_html=content_html, + updated_at=page.get("updated_at", ""), + created_at=page.get("created_at", ""), + cover_url=page.get("cover_url", ""), + page_icon=page.get("page_icon", ""), + ) diff --git a/app/routers/dashboard/workspace.py b/app/routers/dashboard/workspace.py new file mode 100644 index 0000000..d6ad685 --- /dev/null +++ b/app/routers/dashboard/workspace.py @@ -0,0 +1,321 @@ +"""FlowDeck — Dashboard : workspace. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.gitea_client import get_user_gitea_client, gitea + +from ._common import _get_active_workspace, _get_user_id, _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +@router.get("/", response_class=HTMLResponse) +async def dashboard( + request: Request, + search: str = Query(default=""), + show_archived: bool = Query(default=False), +): + """Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + + # ── Not authenticated → show landing page ── + if not user: + # Allow through if DB is empty (fresh install) + try: + with get_conn() as conn: + count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if count == 0: + from app.templating import ENV + env = ENV + template = env.get_template("landing.html") + return template.render() + except Exception: + logger.exception("dashboard") + from app.templating import ENV + env = ENV + template = env.get_template("landing.html") + return template.render() + + # ── Authenticated ── + user_id = user.get("id", 1) + has_gitea = False + try: + with get_conn() as conn: + tok = conn.execute( + "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", + (user_id,), + ).fetchone() + has_gitea = bool(tok) + except Exception: + logger.exception("dashboard") + + if not has_gitea: + # Check if user has any workspace + try: + with get_conn() as conn: + ws_count = conn.execute( + "SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,) + ).fetchone()[0] + if ws_count == 0: + # v5.2.0: first-launch → onboarding wizard + return RedirectResponse("/welcome", status_code=302) + except Exception: + logger.exception("dashboard") + return RedirectResponse("/local-workspace", status_code=302) + + # ── Gitea user → full dashboard ── + try: + repos = await gitea.get_user_repos(page=1, limit=50) + if search: + q = search.lower() + repos = [r for r in repos if q in r.get("full_name", "").lower() + or q in (r.get("description") or "").lower()] + if not show_archived: + repos = [r for r in repos if not r.get("archived", False)] + repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) + except Exception as e: + logger.error("Dashboard error: %s", e) + repos = [] + + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, repos) + template = env.get_template("dashboard.html") + return template.render(request=request, repos=repos, search=search, + show_archived=show_archived, **sidebar) + + +# ═══════════ Workspace ═══════════ + + + + +# ═══════════ Workspace ═══════════ + +@router.get("/workspace", response_class=HTMLResponse) +def workspace_page(request: Request): + """Unified workspace showing all projects.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return RedirectResponse("/auth/login?provider=local", status_code=302) + ctx = {**sidebar, "user": user} + # Pass active workspace for breadcrumb nav menu + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ctx["nav_workspace_id"] = ws["id"] if ws else 0 + template = env.get_template("workspace.html") + return template.render(**ctx) + + + + +@router.get("/gitea-workspace", response_class=HTMLResponse) +def gitea_workspace_page(request: Request): + """Gitea workspace — browse repo files.""" + import json + + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return RedirectResponse("/auth/login?provider=local", status_code=302) + owner = request.query_params.get("owner", "") + repo = request.query_params.get("repo", "") + ws_key = f"{owner}/{repo}" if owner and repo else "" + ws_name = ws_key or "Gitea Workspace" + # Auto-create local workspace mirror for storing local files + local_ws_id = None + if ws_key: + with get_conn() as conn: + existing = conn.execute( + "SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], ws_key, "%gitea_repo%") + ).fetchone() + if existing: + local_ws_id = existing["id"] + else: + c = conn.execute( + "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", + (ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner})) + ) + local_ws_id = c.lastrowid + conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", + (local_ws_id, user["id"], "admin") + ) + conn.commit() + ctx = { + **sidebar, + "user": user, + "active_ws_name": ws_name, + "workspace_key": ws_key, + "gitea_workspace": True, # always true on this page + "gitea_owner": owner, + "gitea_repo": repo, + "workspace_name": ws_name, + "workspace_initial": repo[0].upper() if repo else "G", + "owner": owner, + "repo": repo, + "nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu + } + template = env.get_template("gitea_workspace.html") + resp = HTMLResponse(content=template.render(**ctx)) + resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax") + return resp + + + + +@router.get("/api/workspace/projects") +async def list_workspace_projects(request: Request): + """List all projects: built-in + Gitea + GitHub. + Uses the user's own Gitea token if connected, not the global admin token.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + + builtin = [] + with get_conn() as conn: + rows = conn.execute( + "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" + ).fetchall() + # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne. + counts = {} + if rows: + for c in conn.execute( + "SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format( + ",".join("?" * len(rows)) + ), + [r["id"] for r in rows], + ).fetchall(): + counts[c["parent_id"]] = c["c"] + for r in rows: + builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"}) + + gitea_repos = [] + # Use per-user token if available, otherwise return empty + user_gitea = get_user_gitea_client(request) if user else None + if user_gitea: + try: + repos = await user_gitea.get_user_repos(page=1, limit=50) + for repo in repos: + gitea_repos.append({ + "id": str(repo.get("id", "")), + "name": repo.get("name", ""), + "full_name": repo.get("full_name", ""), + "description": repo.get("description", ""), + "html_url": repo.get("html_url", ""), + "language": repo.get("language", ""), + "forge": "gitea", + }) + except Exception: + logger.exception("list_workspace_projects") + + return {"builtin": builtin, "gitea": gitea_repos, "github": []} + + + + +@router.post("/api/workspace/projects") +def create_workspace_project(request: Request, body: dict = Body(default={})): + name = body.get("name", "").strip() + if not name: + return {"error": "Name required"} + with get_conn() as conn: + cursor = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')", + (name,), + ) + conn.commit() + pid = cursor.lastrowid + return {"id": pid, "name": name, "forge": "builtin"} + + +# ═══════════ Workspace Members API ═══════════ + + + + +# ═══════════ Workspace Members API ═══════════ + +@router.get("/api/workspace/{ws_id:int}/members") +def list_members(request: Request, ws_id: int): + """List all members of a workspace.""" + with get_conn() as conn: + rows = conn.execute( + """SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at + FROM workspace_members wm JOIN users u ON u.id = wm.user_id + WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,) + ).fetchall() + return {"members": [dict(r) for r in rows]} + + + + +@router.post("/api/workspace/{ws_id:int}/members") +def invite_member(request: Request, ws_id: int, body: dict = Body(default={})): + """Invite a user to a workspace by email.""" + email = body.get("email", "").strip() + role = body.get("role", "editor") + if role not in ("owner", "admin", "editor", "viewer"): + return {"error": "Invalid role"}, 400 + with get_conn() as conn: + user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone() + if not user: + return {"error": "User not found"}, 404 + try: + conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", + (ws_id, user["id"], role), + ) + conn.commit() + except Exception: + return {"error": "Already a member"}, 409 + return {"status": "ok", "user_id": user["id"], "role": role} + + + + +@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}") +def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})): + """Change a member's role.""" + role = body.get("role", "editor") + if role not in ("owner", "admin", "editor", "viewer"): + return {"error": "Invalid role"} + with get_conn() as conn: + conn.execute( + "UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", + (role, ws_id, user_id), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") +def remove_member(request: Request, ws_id: int, user_id: int): + """Remove a member from a workspace.""" + with get_conn() as conn: + conn.execute( + "DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", + (ws_id, user_id), + ) + conn.commit() + return {"status": "ok"} + + +# ═══════════ Local Workspace (file/folder CRUD) ═══════════ diff --git a/app/routers/dashboard/workspaces.py b/app/routers/dashboard/workspaces.py new file mode 100644 index 0000000..fb7b4d8 --- /dev/null +++ b/app/routers/dashboard/workspaces.py @@ -0,0 +1,131 @@ +"""FlowDeck — Dashboard : workspaces. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.db import get_conn + +from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +@router.get("/workspaces", response_class=HTMLResponse) +def workspaces_page(request: Request): + """Workspaces list page.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, [], include_workspace=False) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) + ctx = {**sidebar, "user": user} + # Pass active workspace for breadcrumb nav menu (null on workspaces home) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ctx["nav_workspace_id"] = ws["id"] if ws else 0 + template = env.get_template("workspaces.html") + return template.render(**ctx) + + + + +@router.get("/api/workspaces") +def list_workspaces(request: Request): + """List all workspaces for the current user.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + rows = conn.execute( + "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count " + "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC", + (uid,), + ).fetchall() + workspaces = [] + for r in rows: + d = dict(r) + workspaces.append(d) + active = _get_active_workspace(request, user_id=_get_user_id(request)) + return {"workspaces": workspaces, "active_id": active["id"] if active else None} + + + + +@router.post("/api/workspaces") +def create_workspace(request: Request, body: dict = Body(default={})): + """Create a new workspace.""" + name = body.get("name", "New Workspace").strip() + if not name: + return {"error": "Name required"} + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + # Ensure user exists (FK constraint) + uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone() + if not uid_ok: + conn.execute( + "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)", + (uid, user.get("login", "admin") if user else "admin", + user.get("full_name", "Admin") if user else "Admin"), + ) + cursor = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", + (name, uid), + ) + ws_id = cursor.lastrowid + # Add owner as member + conn.execute( + "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", + (ws_id, uid), + ) + conn.commit() + return {"id": ws_id, "name": name} + + + + +@router.put("/api/workspaces/{ws_id:int}") +def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})): + """Rename a workspace.""" + name = body.get("name", "").strip() + if not name: + return {"error": "Name required"} + with get_conn() as conn: + conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id)) + conn.commit() + return {"status": "ok"} + + + + +@router.delete("/api/workspaces/{ws_id:int}") +def delete_workspace(request: Request, ws_id: int): + """Delete a workspace and all its pages.""" + with get_conn() as conn: + conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) + conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,)) + conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/workspaces/{ws_id:int}/select") +def select_workspace(request: Request, ws_id: int): + """Set the active workspace via cookie.""" + response = JSONResponse({"status": "ok", "workspace_id": ws_id}) + response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/") + return response + + +# ═══════════ Settings Page ═══════════ diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 2fdaa49..813cf0a 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.29.0" + "version": "7.30.0" }, "paths": { "/auth/register": {