Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401d0b17ca | ||
|
|
5951c707eb | ||
|
|
f2f2f3209e | ||
|
|
f2e5684e4e | ||
|
|
b56b181c3e | ||
|
|
2fceed0da2 | ||
|
|
436898d86d | ||
|
|
e0237e576f | ||
|
|
189ed5bbca | ||
|
|
ce0d561ade | ||
|
|
95bc861cdb | ||
|
|
ea19d1d050 | ||
|
|
7f998faf7b | ||
|
|
0b251649e5 | ||
|
|
13d5f8625a | ||
|
|
d707a6850a |
@@ -44,6 +44,11 @@ BACKUP_KEEP=30
|
||||
PROJECT_SYNC_ENABLED=true
|
||||
PROJECT_SYNC_INTERVAL_HOURS=1
|
||||
|
||||
# ── Public API v2 (v6.3.0) ──
|
||||
# PUBLIC_API_INSECURE_OK=true autorise le token de dev fd-public-key (jamais en prod).
|
||||
PUBLIC_API_INSECURE_OK=false
|
||||
API_V2_RATE_LIMIT_PER_TOKEN=300
|
||||
|
||||
# ── Email notifications (v4.9.0) ──
|
||||
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
|
||||
SMTP_HOST=
|
||||
|
||||
@@ -109,8 +109,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||
│ │ ├─ collections.py — /db/... Collections │ │
|
||||
│ │ ├─ editor.py — /api/editor/... Block editor │ │
|
||||
│ │ ├─ private.py — /api/private/* Section privée │ │
|
||||
│ │ ├─ public_api.py — /api/public/* Public API │ │
|
||||
│ │ ├─ public_api.py — /api/v1 Public API v1 │ │
|
||||
│ │ ├─ api_v2.py — /api/v2 Public API v2 │ │
|
||||
│ │ │ — Bearer + scopes, CRUD complet │ │
|
||||
│ │ ├─ web_clipper.py — /api/v2/web-clipper Web Clipper │ │
|
||||
│ │ ├─ permissions.py — /api/v2 (ACL) Permissions │ │
|
||||
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ └─ admin.py — /api/admin/* Admin users │ │
|
||||
@@ -1705,6 +1708,6 @@ docker compose restart flowdeck
|
||||
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST** — Tokens d'accès pour intégrations tierces
|
||||
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
@@ -1,5 +1,189 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues)
|
||||
|
||||
> Les synced blocks passent en production et existent enfin dans les databases :
|
||||
> chaque ligne de database gagne une page contenu (éditeur complet), la lecture
|
||||
> résout la source côté serveur (zéro cache périmé) et la mise à jour d'une
|
||||
> source réécrit réellement toutes les pages référentes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Page contenu par ligne de database** — migration 22 (`pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index) ; `app/services/row_pages.py` : `ensure_row_page()` (création lazy, réparation si trashée, workspace hérité de la page hôte, `collection_id` hérité pour les ACL) ; sync de titre bidirectionnelle ligne ↔ page
|
||||
- **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie le `page_id` de contenu d'une ligne (garde `_require_view` collection) ; le peek de toute vue DB (table/board/gallery/list/calendar) passe par là
|
||||
- **Résolution serveur à chaque lecture** — `resolve_content_json()` sur les 2 routes `/pages/{id}` (dashboard + board), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}` et les rooms realtime (`load_room`)
|
||||
- **Rendu public des synced blocks** — branche `synced` dans `_render_blocks_public` (avant : JSON brut en `<p>`) + état « Deleted synced block »
|
||||
- **Éditeur** — état « Deleted synced block » quand la source a été supprimée (au lieu du cache fantôme / « Loading… » éternel)
|
||||
- **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (lecture sans création lazy)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Propagation des synced blocks** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de chaque page référente via `sync_synced_blocks_in_page()` (fonction morte depuis v5.14.0), récursif dans les `children` (colonnes/toggles), broadcast WS seulement après réécriture DB
|
||||
- **Suppression d'une source** — ids des pages référentes collectés avant la cascade FK, `_synced_deleted` marqué dans le stocké (`mark_synced_block_deleted`), broadcast dédié `manager._broadcast_synced_to()`
|
||||
- **Ouverture des lignes de database** — le peek appelait `/pages/{rowId}` qui lit la table `pages` : avec des ids croisés il ouvrait une page au hasard (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint dédié
|
||||
- **Listings** — les pages contenu (`parent_section='DbRow'`) sont exclues (`AND collection_row_id IS NULL`) de l'arbre sidebar, des racines workspace, de la liste des pages du board, des projets builtins, de l'arbre Library et du `page_count` des workspaces
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v65_synced_db.py` — **17 tests** ; suite complète **742 verts** (`pytest -n auto`) ; `ruff check app tests` OK ; `eslint static/js` 0 problème
|
||||
|
||||
## v6.4.0 (2026-09-22) — Realtime editing (production)
|
||||
|
||||
> Le realtime passe en mode production : résolution de conflits au-delà du last-write-wins (merge à trois versions) et édition à grande échelle (broadcast non bloquant, coalescence des curseurs, corrections de fuites, observabilité).
|
||||
|
||||
### Added
|
||||
|
||||
- **Merge à 3-voix (diff3-lite)** — nouveau `app/services/realtime_merge.py` :
|
||||
- `merge_text_3way(base, current, incoming)` : fusion de caractères. Les régions modifiées qui ne se chevauchent pas sont **toutes conservées** (les deux saisies survivent) ; ordre d'arrivée indifférent ; un chevauchement réel retombe en LWW et est signalé.
|
||||
- `merge_block_3way(base, current, incoming)` : fusion **champ-par-champ**. Serveur et client modifiant des champs différents n'ont plus de conflit ; conflit limité au seul champ divergent (plus « tout le bloc perdu »).
|
||||
- Fonctions pures, testées sans WebSocket ni base de données.
|
||||
- **Protocole `base`** — chaque `update` envoyé par le client embarque la version du bloc dont dérive sa saisie ; le serveur calcule `merge_block_3way(base, current, incoming)`. Sans `base` (ancien client) → LWW historique, rétro-compatible.
|
||||
- **Adoption du résultat fusionné** — l'`ack` renvoie le bloc fusionné + drapeau `conflict` ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit. Le serveur diffuse toujours le bloc final fusionné pour convergence de tous les pairs.
|
||||
- **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room entière.
|
||||
- **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), en préservant l'ordre des messages importants.
|
||||
- **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / fenêtre 10 s) ; au-delà, réponse `ack stale` sans application.
|
||||
- **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions (dont totales), ops, merges, conflits, déconnexions lentes, + détail par page.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Déconnexion des clients trop lents** — file sortante pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects`, pour qu'une room ne stagne jamais sur un pair mortel.
|
||||
- `app/templates/_page_editor_realtime.html` — les updates incluent désormais `base` ; nouveau traitement de l'`ack` avec `merged`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fuite de rooms** — une tentative de connexion WS sur une page inexistante (4404) enregistrait une `Room` orpheline en mémoire pour toujours ; la room n'est plus enregistrée tant que la page n'est pas validée.
|
||||
- **`room_state()` sur page inexistante** — retournait/plantait sur `None` ; retourne désormais `{"blocks": [], "title": "", "version": 0}` sans laisser d'entrée dans `_rooms`.
|
||||
|
||||
### Tests
|
||||
|
||||
- **26 nouveaux tests** `tests/test_realtime_v64.py` : merge purs (régions disjointes, ordre indépendant, chevauchement → conflit, suppression de champ, id préservé), protocole WS (ack fusionné, convergence de 2 clients sur le même bloc, rétro-compat LWW sans `base`, conflit scalaire), fuite 4404, coalescence des curseurs, endpoint stats, anti-flood.
|
||||
- **14 tests** `tests/test_realtime.py` existants préservés (non-régression).
|
||||
- `ruff check app tests` OK · `eslint static/js` 0 problème.
|
||||
|
||||
---
|
||||
|
||||
## v6.3.0 (2026-09-21) — API publique complète v2 (REST + scopes + OpenAPI)
|
||||
|
||||
> L'API publique `/api/v2` devient une surface REST complète façon Notion : CRUD sur tous les domaines (collections, pages, propriétés, vues, commentaires, notifications, favoris, tags, partage, sprints, templates, forges, admin…), auth Bearer + scopes hiérarchiques, pagination/filtres/tri, erreurs RFC 7807, idempotence, audit et webhooks. Wrappers sur les services existants — un seul chemin de code.
|
||||
|
||||
### Migrations (v20)
|
||||
|
||||
- `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'` et `expires_at TIMESTAMP` (ALTER idempotents, rétro-compatibles : anciens tokens → `read,write`).
|
||||
- Nouvelles tables `api_audit_log` (user, token, action, ressource, IP), `webhook_deliveries` (statut, http_code, durée, payload) et `idempotency_keys` (clé → réponse rejouée).
|
||||
|
||||
### Auth, scopes & conventions (`app/services/api_v2_helpers.py`)
|
||||
|
||||
- **Bearer unifié** `resolve_bearer_token()` / `get_bearer_user()` : hash sha256 sur `api_tokens`, support `extension_devices` (clipper) et legacy `user_tokens`, vérification `revoked` + `expires_at`, mise à jour `last_used_at`.
|
||||
- **Scopes hiérarchiques** `read < write < admin` (un scope supérieur satisfait un besoin inférieur), dépendance `require_scope()` → 403 explicite ; `validate_scopes_input()` → 400 sur scope inconnu.
|
||||
- **Pagination** `parse_pagination()` (défaut 30 / max 100) + header `X-Total-Count` ; **ISO-8601 UTC** via `to_iso8601()` ; parsing des colonnes `*_json`.
|
||||
- **Erreurs RFC 7807** `application/problem+json` (`type/title/status/detail/instance`) pour tout `/api/v2`, via le handler unifié sur `StarletteHTTPException` (`app/main.py`).
|
||||
- **Idempotence** `Idempotency-Key` sur les POST de création (`check_idempotency` / `store_idempotency`).
|
||||
- **Audit** `audit_log()` sur toutes les mutations v2 (`api_audit_log`).
|
||||
- **Rate limit par token** `check_v2_rate_limit()` (300 req/min, configurable `API_V2_RATE_LIMIT_PER_TOKEN`).
|
||||
- **`public_api_insecure_ok`** : le token de dev `fd-public-key` n'est accepté que si `PUBLIC_API_INSECURE_OK=true` (dev/test) — refusé par défaut en production.
|
||||
|
||||
### Router `app/routers/api_v2.py` (`prefix /api/v2`, ~100 endpoints)
|
||||
|
||||
- **Tokens** : `POST /tokens` (name, scopes, expires_at, montré une fois), `GET /tokens` (prefix only, jamais le hash), `DELETE /tokens/{id}`, `POST /tokens/{id}/rotate`.
|
||||
- **Users** : `GET/PATCH /users/me`, `GET /users/search`, préférences.
|
||||
- **Workspaces & membres** : CRUD + `GET/POST/PATCH/DELETE .../members`.
|
||||
- **Collections** : CRUD, `/linked`, `/task`, `/sources`.
|
||||
- **Pages** : `GET /collections/{id}/pages` (filtres `filter[prop]`, `sort`, `fields`, `query`), CRUD, `/restore`, `/move`, `/sub-items`, `/dependencies`.
|
||||
- **Propriétés** : CRUD, `/relation`, `evaluate-formula`, `compute-rollup`.
|
||||
- **Vues & dashboards** : CRUD + `save-as`.
|
||||
- **Comments / mentions / notifications / favoris / tags / recents**.
|
||||
- **Partage & publication** : `pages/{id}/shares`, `publish` / dé-publish (`/p/<slug>`).
|
||||
- **Historique** (`page_history` + `page_versions`) + restore.
|
||||
- **Sprints** (CRUD, assign, burndown), **templates** (page + database, apply), **export/import** (MD/HTML/PDF, CSV).
|
||||
- **Forges** : `GET /projects`, `/projects/{owner}/{repo}/tree`.
|
||||
- **Recherche** FTS5 (`GET /search`, repli LIKE).
|
||||
- **Admin** : users (list/patch/delete) + `GET /admin/audit-logs`.
|
||||
- **Webhooks** : CRUD subscriptions + `POST /{id}/test` + `GET /{id}/deliveries` (CRUD simple d'abord).
|
||||
|
||||
### OpenAPI & config
|
||||
|
||||
- `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins).
|
||||
- `.env.example` : `PUBLIC_API_INSECURE_OK=false`, `API_V2_RATE_LIMIT_PER_TOKEN=300`.
|
||||
- `VERSION` et `app/main.py` passés à **6.3.0**.
|
||||
|
||||
### Tests & robustesse
|
||||
|
||||
- `tests/test_public_api_v2.py` — **24 tests** (auth 401, token lifecycle, scopes read/write/admin, pagination + `X-Total-Count`, CRUD collections/pages/propriétés/vues, filtres, RFC7807, idempotence, search, notifications, tags, sharing, webhooks, workspaces, sprints, templates, admin).
|
||||
- `tests/conftest.py` + fixtures locales : `PUBLIC_API_INSECURE_OK=true` (le token de dev reste testable sans impacter la prod).
|
||||
- Suite complète **668 verte** (`pytest -n auto`) ; `ruff check app tests` OK.
|
||||
|
||||
## v6.2.1 (2026-09-20) — Web Clipper : polish & fix bloc bookmark
|
||||
|
||||
> Patch UX et correctif bloc bookmark pour l'extension v6.2.0.
|
||||
|
||||
- **Extension — bouton flottant** — rond transparent draggable (évite de masquer le contenu), toggle d'affichage persistant (chrome.storage), clic → popup de capture ; position restaurée au reload.
|
||||
- **Refresh auto sidebar** — après `POST /api/v2/web-clipper/clip` le workspace sidebar est rafraîchi sans reload (polling + event `clipper:clipped`) pour que la page clippée apparaisse immédiatement.
|
||||
- **Bloc `bookmark`** — le service `create_page_from_clip()` renvoie désormais un bloc `bookmark` fidèle au payload OG (URL d'origine + `embed_src` résolu via `embeds.py`), rendu correct en éditeur / page publique `/p/<slug>` / exports MD/HTML/PDF (préserve favicon + description).
|
||||
- **Fix éditeur** `7f998fa` — SyntaxError `duplicate inner` dans `_page_editor_scripts.html` (variable `inner` redéclarée lors du drag & drop multi-sélection) corrigé ; autosave + WS realtime non bloqués.
|
||||
- **Tests & build** — `flowdeck-clipper.zip` régénéré (`static/extension/`), `ruff`/`eslint` verts, `16 tests` `test_web_clipper.py` verts.
|
||||
|
||||
## v6.2.0 (2026-09-19) — Web Clipper : extension navigateur (capture article/selection/bookmark/screenshot)
|
||||
|
||||
> Capturez n'importe quelle page web en une page FlowDeck : article complet, sélection, bookmark ou screenshot — depuis une extension Manifest V3 (Chrome/Edge/Firefox) + API directe `POST /api/v2/web-clipper/clip`.
|
||||
|
||||
### Extension navigateur (`extension/` + `static/extension/`)
|
||||
|
||||
- **Manifest V3** — `manifest.json` (permissions `activeTab, storage, scripting, contextMenus`, `host_permissions <all_urls>`), `background.js` (service worker OAuth + clip → fetch Bearer), `content.js` (bouton flottant + menu contextuel sélection + `Ctrl+Shift+C`), `popup.html`/`popup.js` (sélection workspace, type de capture), `clipper.css`, icônes 16/32/48/128, bundle `flowdeck-clipper.zip` servi à `/static/extension/`.
|
||||
- **Types de capture** — `article` (HTML complet → `sanitize_html` + `html_to_blocks`), `selection` (sélection HTML → Markdown), `bookmark` (URL + OG → carte bookmark v5.5.0), `screenshot` (base64 → upload image + page). Cap `10 MB` / `200 blocs`, garde SSRF inexistante (validation URL), sanitisation HTML côté serveur.
|
||||
|
||||
### Serveur (`app/routers/web_clipper.py` + `app/services/web_clipper.py`)
|
||||
|
||||
- **Endpoints** `prefix /api/v2/web-clipper` — `POST /clip` (crée page via `create_page_from_clip()` + `log_clip()`), `GET /status` (auth + compteurs devices/clips), `POST /auth/verify` (enregistre device `register_device()` → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` ; page HTML `GET /extensions` (téléchargement + liste devices/clips).
|
||||
- **Auth triple** — session cookie `flowdeck_session` OU Bearer `api_tokens` (hash `sha256`) OU Bearer `extension_devices` OU legacy `user_tokens` (`_user_from_request()`), rate-limit `50 clips/heure/device` (`_check_rate_limit`, 429), payload normalisé (`url`, `title`, `content`, `content_type`, `selection_html`, `image_base64`, `tags`, `target_workspace_id`).
|
||||
- **Tables migration 19** — `extension_devices` (`user_id, extension_name, device_id, device_name, token_hash, scopes, last_used_at, revoked, UNIQUE(user_id,extension_name,device_id)`) + `extension_clips` (`user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title`) avec index `idx_ext_*`.
|
||||
- **Settings UI** — onglet Extensions dans `app/templates/settings.html` (devices, clips count, revoke, token copy).
|
||||
|
||||
### Tests & wiring
|
||||
|
||||
- `tests/test_web_clipper.py` — **16 tests** (sanitize, blocks, article/bookmark/selection/screenshot, Bearer, rate-limit, devices, `/extensions`).
|
||||
- Wiring `app/main.py:50,158` — `web_clipper_api_router` + `web_clipper_router` inclus ; `VERSION` bump `6.1.0 → 6.2.0`.
|
||||
|
||||
## v6.1.0 (2026-09-19) — Granular Permissions : page / collection / property ACL + groupes + audit
|
||||
|
||||
> Permissions fines héritables : chaque page / database / propriété peut être restreinte à des utilisateurs ou groupes explicites. L'héritage suit la chaîne page → collection → workspace (moindre privilège), avec bypass owner/admin et audit complet.
|
||||
|
||||
### Base de données (migration 18)
|
||||
|
||||
- Tables `user_groups` (workspace-scoped, UNIQUE(name)), `group_members` (N-ary), `page_permissions`, `collection_permissions`, `property_permissions` (user_id XOR group_id, CHECK, UNIQUE), `permission_audit_log`.
|
||||
- Colonnes `pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type` (`inherit|restricted|private`, défaut `inherit`).
|
||||
- Index `idx_pp_page`, `idx_pp_user`, `idx_cp_collection`, `idx_propp_prop`, `idx_perm_audit_res`, `idx_gm_*`.
|
||||
|
||||
### PermissionManager (`app/services/permission_manager.py`)
|
||||
|
||||
- Résolution héritage + moindre privilège : grant explicite sur page > grant collection > rôle workspace ; `restricted`/`private` sans grant → 404.
|
||||
- Méthodes page : `get_page_permission()`, `can_view/edit/comment/manag` ; collection : `get_collection_permission()`, `can_view/edit/manag` ; propriété : `can_view/edit_property()`, `get_visible_properties()`.
|
||||
- Groupes : `create_group()`, `add/remove_user_from_group()`, `get_groups_for_workspace()`, `get_group_members()`, `is_workspace_admin()`, `user_group_ids()`.
|
||||
- Helpers : `_explicit_grant_role()` (best rank user+groups), `_collection_role()`, `_property_*`, `_owns_workspace()`, `_is_admin()`.
|
||||
- Cache 60 s (`_cached()`) + `invalidate()` appelé après chaque grant/revoke/type_change.
|
||||
- `log_permission_change()` (audit, never-throw).
|
||||
|
||||
### API (`app/routers/permissions.py` — `prefix /api/v2`)
|
||||
|
||||
- Pages : `GET /pages/{id}/permissions` (grants + mine + type + can_manage), `GET /pages/{id}/permissions/mine`, `POST /pages/{id}/permissions` (user_id|group_id + role viewer/commenter/editor/owner), `POST /pages/{id}/permissions/batch`, `DELETE /pages/{id}/permissions/{perm_id}`, `POST /pages/{id}/permission-type`.
|
||||
- Collections : `GET /collections/{id}/permissions`, `POST /collections/{id}/permissions`, `DELETE /collections/{id}/permissions/{perm_id}`, `POST /collections/{id}/permission-type`, `GET /collections/{id}/properties/visible` (split visible/hidden).
|
||||
- Properties : `GET /collections/{cid}/properties/{pid}/permissions`, `POST …/permissions` (role viewer|editor), `DELETE …/permissions/{perm_id}` (extra_cols collection_id).
|
||||
- Groupes : `GET /groups?workspace_id`, `POST /groups`, `PUT /groups/{id}`, `DELETE /groups/{id}`, `GET/POST /groups/{id}/members`, `DELETE /groups/{id}/members/{uid}` (workspace owner/admin only).
|
||||
- Access pickers + audit : `GET /users?workspace_id&q`, `GET /audit/permissions?limit` (owner/admin, 500 max).
|
||||
- Validation : 401 sans session, 403 sans can_manage, 400 rôle invalide / cible manquante, 404 user/group/property inconnu.
|
||||
|
||||
### Guards existants
|
||||
|
||||
- `board.py` : `GET /board/api/pages/{id}` (404 si !can_view_page), `PUT /board/api/pages/{id}` (404 si non-view, 403 si viewer/commenter, 423 si locked reste prioritaire).
|
||||
- `collections.py` : `DELETE /db/api/{id}` (404 si !can_view_collection, 403 si !can_manage), `GET /db/pages/{id}/api` (404), `PUT/DELETE /db/pages/{id}/api` (403), `GET /db/{id}/properties/api` filtré par visible, `_require_view/_require_edit` helpers avec `_session_user()` (pas de fallback admin sur null session).
|
||||
|
||||
### UI (backend-ready)
|
||||
|
||||
- Endpoints prêts pour panneau Permissions de l'éditeur (icône 🔒), panneau collection, masquage colonnes via `visible`/`hidden`, et gestion des groupes en Settings. Les grants sont consommés par `can_view_property` / `get_visible_properties` côté vues.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v60_granular_permissions.py` — **21 tests** : inherit allow + edit 403, restricted hidden (owner 200 / member 404), grant viewer unlock + viewer cannot edit, editor can edit but not manage, revoke 404, private + admin override, mine + batch, permission-type via API, restricted collection hidden (API 404 + HTML 302), collection viewer 403 create/delete, owner delete, collection editor create pages, property visibility, property hidden, property grant 403, group grant inherits + listing shows group, group removal revokes, groups listing/members + non-owner 403, audit log (grant+type_change, member 403), endpoints 401, validation 404/400. Suite 21/21 verte.
|
||||
|
||||
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
|
||||
|
||||
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,10 +49,12 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
### API & Intégrations (v6.3)
|
||||
- **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs`
|
||||
- **API publique v1**: `/api/v1` (lecture seule, compat)
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
|
||||
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
|
||||
- **PWA**: manifest.json + service worker, offline support
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
@@ -85,7 +87,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
python3 -m pytest tests/ -v # 725/725 passent
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -695,6 +695,15 @@ Détails livrés :
|
||||
- [x] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [x] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
|
||||
### v5.15.0 — Webhooks v2 ✅ (2026-09-21)
|
||||
> **Objectif** : webhooks sécurisés, fiables et complets pour les intégrations tierces. **COMPLETED**.
|
||||
|
||||
- [x] **Signature HMAC SHA-256** — header `X-FlowDeck-Signature` sur chaque payload, vérification côté receveur
|
||||
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
|
||||
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
|
||||
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
|
||||
- [x] **21 tests** `tests/test_webhooks_v2.py` (HMAC, retries, événements, intégration)
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
|
||||
@@ -714,15 +723,170 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v6.1.0 — Granular Permissions ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : page-level, collection-level & property-level ACL + groupes + audit. **COMPLETED**.
|
||||
|
||||
- [x] **Page permissions** — modes `inherit|restricted|private` (`pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type`), grants explicites user/group (`page_permissions` role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
|
||||
- [x] **Collection permissions** — `collection_permissions` + permission_type, `/db/{id}` et `/db/{id}/api` masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
|
||||
- [x] **Property-level visibility** — `property_permissions` (viewer|editor), `GET /db/{id}/properties/api` filtré par `get_visible_properties()`, `GET /api/v2/collections/{id}/properties/visible` (visible/hidden), grant = owner collection uniquement
|
||||
- [x] **Groupes réutilisables** — tables `user_groups` + `group_members` (workspace-scoped, UNIQUE(name)), CRUD `/api/v2/groups` + `/groups/{id}/members`, grant par `group_id` (page/collection/property), retrait membre révoque l'accès
|
||||
- [x] **API** — `app/routers/permissions.py` : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit `GET /api/v2/audit/permissions`
|
||||
- [x] **Guards** — `board.py` (`GET/PUT /board/api/pages/{id}`) + `collections.py` (`delete collection`, `GET/PUT/DELETE page`, `properties`), 403/404 conformes, is_admin/owner bypass + `_session_user()` (no admin fallback)
|
||||
- [x] **PermissionManager** — `app/services/permission_manager.py` étendu : `_explicit_grant_role()` (best rank user+groups), `get_page/collection_permission()`, `can_view/edit_page|collection`, `can_view/edit_property()`, `get_visible_properties()`, groups, `log_permission_change()`, cache 60s + `invalidate()`
|
||||
- [x] **Audit** — table `permission_audit_log` (resource_type, action grant/revoke/type_change/group_*), index, log sur tous les mutateurs, `GET /api/v2/audit/permissions` (owner/admin only, limit 500)
|
||||
- [x] **Migration 18** — `migrations.py` : création 6 tables + 3 colonnes `permission_type` + indexes (idempotent)
|
||||
- [x] **Tests** — `tests/test_v60_granular_permissions.py` **21 tests** (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)
|
||||
|
||||
## v6.2.0 — Web Clipper : extension navigateur ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : capturer n'importe quelle page web en page FlowDeck (article, sélection, bookmark, screenshot) depuis une extension Manifest V3 + API directe. **COMPLETED**.
|
||||
|
||||
- [x] **Extension Manifest V3** — `extension/` + `static/extension/` (manifest, `background.js`, `content.js`, `popup.html/js`, `clipper.css`, icônes 16/32/48/128, `flowdeck-clipper.zip` servi à `/static/extension/`)
|
||||
- [x] **4 types de capture** — article (HTML complet → `sanitize_html` + `html_to_blocks`), sélection, bookmark (carte OG v5.5.0), screenshot (base64) ; cap 10 MB / 200 blocs
|
||||
- [x] **Serveur** — `POST /api/v2/web-clipper/clip`, `GET /status`, `POST /auth/verify` (register device → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` + page HTML `GET /extensions` ; auth triple (session OU Bearer `api_tokens` OU Bearer `extension_devices` OU legacy `user_tokens`), rate-limit 50/h/device
|
||||
- [x] **Tables migration 19** — `extension_devices`, `extension_clips` + index `idx_ext_*`
|
||||
- [x] **Settings UI** — onglet Extensions (devices, clips count, revoke)
|
||||
- [x] **16 tests** `tests/test_web_clipper.py` ; `VERSION` 6.2.0 ; wiring `app/main.py:50,158`
|
||||
|
||||
### v6.2.1 — Web Clipper polish ✅ (2026-09-20)
|
||||
|
||||
- [x] **Bouton flottant rond transparent draggable** — toggle d'affichage persistant, `clipper:clipped` refresh auto sidebar
|
||||
- [x] **Fix bloc bookmark** — `create_page_from_clip()` émet un bloc `bookmark` fidèle (OG + `embed_src` résolu), rendu/correct en éditeur + `/p/<slug>` + exports
|
||||
- [x] Fix `duplicate inner` SyntaxError dans `_page_editor_scripts.html`
|
||||
- [x] `flowdeck-clipper.zip` régénéré
|
||||
|
||||
## v6.3.0 — API publique complète v2 ✅ (2026-09-21)
|
||||
|
||||
> **Objectif** : REST API documentée OpenAPI, CRUD complet, un seul chemin de code (wrappers sur les services internes). Parité `docs/API_GUIDE_V6.md` §4 (~80 endpoints) + scopes hiérarchiques `read < write < admin`. **COMPLETED**.
|
||||
> **Route** : `feat/v6-api-v2` → `develop` → `main` — livraison **en une fois** (tous domaines).
|
||||
> **Doc** : [`docs/API_GUIDE_V6.md`](/docs/API_GUIDE_V6.md) · OpenAPI : `/docs` + `docs/openapi-v2.json` (402 chemins)
|
||||
|
||||
#### Phase 0 — Roadmap & doc catch-up ✅
|
||||
|
||||
- [x] Tagguer v6.2.0/v6.2.1 dans `CHANGELOG.md` + `ROADMAP.md` + `docs/V6_Web_Clipper.md` → `COMPLETED`
|
||||
- [x] Détailler v6.3.0 phases 1-8 dans `ROADMAP.md` (plan gelé)
|
||||
|
||||
#### Phase 1 — Migrations socles (v20) ✅
|
||||
|
||||
- [x] `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'`, `expires_at TIMESTAMP` (idempotent, rétro-compat)
|
||||
- [x] `webhook_deliveries` : `id, webhook_id FK, status, http_code, error, duration_ms, attempt, created_at`
|
||||
- [x] `api_audit_log` : `id, user_id, token_id, action, resource_type, resource_id, ip, created_at`
|
||||
- [x] `idempotency_keys` : `key TEXT PRIMARY KEY, user_id, response_json, created_at`
|
||||
|
||||
#### Phase 2 — Helpers & auth v2 unifiée (scopes hiérarchiques) ✅
|
||||
|
||||
- [x] `app/config.py` : `public_api_insecure_ok: bool = False` — `fd-public-key` accepté seulement si `True` (dev local)
|
||||
- [x] `app/services/api_v2_helpers.py` : `parse_pagination()` (+`X-Total-Count`), `to_iso8601()`, handler RFC 7807 `application/problem+json`, `require_scope()` (hiérarchie `admin ⊇ write ⊇ read`), `resolve_bearer_token()` / `get_bearer_user()` (hash sha256, `revoked` + `expires_at` + scopes, `last_used_at`, `extension_devices`)
|
||||
- [x] Auth Bearer unifié partagé (clipper + legacy `user_tokens` supportés) ; handler d'erreurs unifié sur `StarletteHTTPException`
|
||||
|
||||
#### Phase 3 — Tokens CRUD v2 ✅
|
||||
|
||||
- [x] `POST /api/v2/tokens` (`name, scopes, expires_at`) → `fd_{urlsafe(32)}` hashé, montré une fois
|
||||
- [x] `GET /api/v2/tokens` (prefix only), `DELETE /api/v2/tokens/{id}` (revoke), `POST /api/v2/tokens/{id}/rotate`
|
||||
|
||||
#### Phase 4 — Wrappers read (pagination/filtres/tri/fields) ✅
|
||||
|
||||
- [x] `app/routers/api_v2.py` (`prefix="/api/v2"`, tag `api-v2`) — `GET /collections`, `GET /collections/{id}`, `GET /pages/{id}`, `GET /collections/{id}/pages?filter[]=&sort=&fields=&query=`, `GET /search?query=&workspace_id=&type=` (FTS5), header `X-Total-Count`, `filter[]` AND, `sort=prop/-prop`
|
||||
|
||||
#### Phase 5 — Wrappers write critiques (`Idempotency-Key`) ✅
|
||||
|
||||
- [x] Collections : `POST/GET/PATCH/DELETE /collections/{id}` + `/linked`, `/task`, `/sources`
|
||||
- [x] Pages : `POST/GET/PATCH/DELETE /pages/{id}` + `/restore`, `/move`, `/sub-items`, `/dependencies`
|
||||
- [x] Properties : `GET/POST /collections/{id}/properties`, `PATCH/DELETE /properties/{id}`, `POST .../relation`, `evaluate-formula`, `compute-rollup`
|
||||
- [x] Views/Dashboards/Comments/Notifications/Favorites/Tags/Recents/Sharing/History/Sprints/Templates/Export/Workspaces/Users/Admin — regroupés par ressource
|
||||
|
||||
#### Phase 6 — Webhooks v2 — CRUD simple ✅
|
||||
|
||||
- [x] `GET/POST/PATCH/DELETE /api/v2/webhooks`, `POST /api/v2/webhooks/{id}/test` (ping)
|
||||
- [x] `GET /api/v2/webhooks/{id}/deliveries` (journal basique)
|
||||
- [x] *(reporté v6.4, livré plus tôt)* **v5.15.0** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events (Webhooks v2)
|
||||
|
||||
#### Phase 7 — Forges & ressources restantes ✅
|
||||
|
||||
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
|
||||
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
|
||||
- [ ] *(reporté)* : migration de `sync.py` vers Bearer (reste session, CSRF-exempt)
|
||||
|
||||
#### Phase 8 — OpenAPI, tests & docs ✅
|
||||
|
||||
- [x] `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins)
|
||||
- [x] `tests/test_public_api_v2.py` — **24 tests** (auth scopes, pagination, filtres, RFC 7807, idempotency, webhooks deliveries, CRUD multi-domaines)
|
||||
- [x] Vérif `ruff check app tests` + `pytest -n auto` → **668 verte**
|
||||
|
||||
## v6.4.0 — Realtime editing (production) ✅ (2026-09-22)
|
||||
|
||||
> **Objectif** : passer le realtime v5.13.0 en « production » — résolution de
|
||||
> conflits au-delà du last-write-wins + édition à grande échelle (broadcast non
|
||||
> bloquant, plusieurs rooms/pages, observabilité). **COMPLETED**.
|
||||
|
||||
#### Conflits au-delà du LWW
|
||||
|
||||
- [x] **Merge à 3 voix (diff3-lite)** — nouveau service `app/services/realtime_merge.py` : `merge_text_3way()` (merge de caractères) + `merge_block_3way()` (merge champ-par-champ), fonctions pures et testées sans WebSocket ni base
|
||||
- [x] **Régions disjointes conservées** — deux utilisateurs tapant à des endroits différents du *même* bloc voient leurs deux saisies survivre (au lieu d'écraser l'une par l'autre) ; ordre d'arrivée indifférent
|
||||
- [x] **Chevauchement réel → LWW par champ + drapeau** — un conflit n'est plus « tout le bloc perdu » mais limité au champ concerné ; `conflict: true` renvoyé dans l'`ack` et le broadcast
|
||||
- [x] **Protocole `base`** — le client embarque dans chaque `update` la version du bloc dont dérive sa saisie ; le serveur fait `merge_block_3way(base, current, incoming)` ; sans `base` → LWW historique (rétro-compat ancien client)
|
||||
- [x] **Adoption côté client** — l'`ack` renvoie le bloc fusionné ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit
|
||||
- [x] **Broadcast du résultat fusionné** — le serveur diffuse toujours le bloc final fusionné (jamais la proposition brute) pour convergence garantie de tous les clients
|
||||
|
||||
#### Échelle & robustesse
|
||||
|
||||
- [x] **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room
|
||||
- [x] **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), tout en préservant l'ordre des messages importants
|
||||
- [x] **Déconnexion des clients trop lents** — file pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects` (évite qu'une room entière stagne sur un pair mortel)
|
||||
- [x] **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / 10 s) ; au-delà, réponse `ack stale` sans application
|
||||
- [x] **Fix fuite de rooms** — une connexion 4404 n'enregistre plus de `Room` orpheline en mémoire ; `room_state()` sur page inexistante retourne un dict vide au lieu de planter sur `None`
|
||||
- [x] **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions, ops, merges, conflits, déconnexions lentes + détail par page
|
||||
|
||||
Détails livrés :
|
||||
- `app/services/realtime_merge.py` — merge 3-voix (nouveau, ~170 lignes)
|
||||
- `app/services/realtime_server.py` — `RTConn` (file + writer + budget), `_apply()` avec merge, `_evict_slow()`, `stats()`, fix fuites
|
||||
- `app/routers/realtime.py` — endpoint `GET /api/realtime/stats`
|
||||
- `app/templates/_page_editor_realtime.html` — envoi de `base`, adoption du bloc fusionné, toast de conflit
|
||||
- **26 tests** `tests/test_realtime_v64.py` (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; **14 tests** `tests/test_realtime.py` préservés
|
||||
- `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
- [x] **Version** — 6.4.0
|
||||
|
||||
---
|
||||
|
||||
## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
|
||||
> **Objectif** : passer les synced blocks en « production » (résolution +
|
||||
> propagation fiables partout) et les faire vivre dans les databases/vues —
|
||||
> le « reste en v6 » du point **v5.14.0**. **COMPLETED**.
|
||||
|
||||
#### Contenu des lignes de database (le point « databases »)
|
||||
- [x] **Migration 22** — `pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index partiel ; chaque ligne de database gagne une **page contenu** (page `blocks` standard) portant son éditeur complet
|
||||
- [x] **Service `app/services/row_pages.py`** — `ensure_row_page()` : création lazy/réparation (page trashée restaurée, `parent_section='DbRow'`), workspace hérité de la page hôte (DB full-page → page parente inline → `collections.gitea_owner/repo`), `collection_id` hérité pour que les ACL de collection s'appliquent au contenu
|
||||
- [x] **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie `{page_id}` (garde `_require_view` de la collection) ; utilisé par le peek de **toute** vue (table/board/gallery/list/calendar)
|
||||
- [x] **Fix bug d'ouverture de ligne** — le peek appelait `/pages/{rowId}` qui lit la table `pages` → avec des ids croisés il ouvrait une **page au hasard** (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe `/pages/{pageId}`
|
||||
- [x] **Titres synchronisés bidirectionnellement** — renommer la ligne (`PUT /db/pages/{id}/api`) → page contenu ; renommer dans l'éditeur (`PUT /board/api/pages/{id}`, `POST .../blocks`, `PUT /api/pages/{id}/rename`) → ligne
|
||||
- [x] **Cascade** — supprimer une ligne supprime sa page contenu (FK `ON DELETE CASCADE`, refs `page_synced_blocks` en cascade)
|
||||
- [x] **Exclusions des listings** — `AND collection_row_id IS NULL` sur : arbre sidebar (`_build_page_tree`), racines workspace (`_load_workspace_pages`), liste des pages du board, projets builtins, arbre Library (`_build_tree_children` + API tree), `page_count` des workspaces ; `parent_section='DbRow'` les tient hors des sections Private/Trash
|
||||
|
||||
#### Résolution serveur + propagation (le point « production »)
|
||||
- [x] **Résolution à chaque lecture** — `resolve_content_json()` (`app/services/synced_blocks.py`) appliqué sur : les 2 routes de rendu `/pages/{id}` (dashboard + board, dual-route), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}`, et les rooms realtime (`load_room`) → le cache `_synced_content` périmé n'est plus jamais servi
|
||||
- [x] **Propagation écrite réelle** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de **chaque** page référente (`sync_synced_blocks_in_page`, jusqu'ici jamais appelé = code mort depuis v5.14.0) **avant** le broadcast WS ; récursif dans les `children` (colonnes, toggles)
|
||||
- [x] **Suppression de source** — ids des pages référentes collectés **avant** la cascade FK, `mark_synced_block_deleted()` marque `_synced_deleted` dans le stocké, broadcast dédié `manager._broadcast_synced_to()`
|
||||
- [x] **État « deleted »** — `resolve_synced_block()` marque `_synced_deleted` (source absente) ; rendu dédié dans l'éditeur (« Deleted synced block » au lieu d'un cache fantôme / « Loading… » éternel)
|
||||
- [x] **Rendu public des synced blocks** — branche `synced` ajoutée à `_render_blocks_public` (avant : JSON brut en `<p>`) + état deleted
|
||||
- [x] **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (sans création lazy en lecture)
|
||||
|
||||
#### Tests
|
||||
- [x] **`tests/test_v65_synced_db.py` — 17 tests** (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, `content_page_id` v2)
|
||||
- [x] Suite complète **742 verts** (`pytest -n auto`) · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
- [x] **Version** — 6.5.0 (VERSION + `app/main.py`)
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
|
||||
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
|
||||
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600)
|
||||
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
|
||||
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
|
||||
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
|
||||
- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
|
||||
|
||||
---
|
||||
|
||||
@@ -761,11 +925,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ✅
|
||||
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced PWA · Perms
|
||||
& Tests Templates & COMPLETED Automations Interactions Média riche blocks Clipper · API v2
|
||||
Collaboration Realtime, de bloc (undo/ (embed, + DB
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2)
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
|
||||
|
||||
*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues)
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
| v3.0 | Auth locale, Multi-Forge, Standalone | ✅ | — |
|
||||
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ |
|
||||
| v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ |
|
||||
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
@@ -58,5 +61,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -43,6 +43,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
# Public API v2 (v6.3.0)
|
||||
public_api_insecure_ok: bool = False # if True, fd-public-key is accepted (dev only)
|
||||
api_v2_rate_limit_per_token: int = 300 # req/min per token for /api/v2
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:////data/flowdeck.db"
|
||||
|
||||
@@ -64,6 +68,10 @@ class Settings(BaseSettings):
|
||||
reminders_enabled: bool = True
|
||||
reminder_scan_interval_seconds: int = 60
|
||||
|
||||
# Webhooks outbound (v6.4.0) — retry of failed deliveries
|
||||
webhook_retry_enabled: bool = True
|
||||
webhook_retry_interval_seconds: int = 60
|
||||
|
||||
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
||||
# email notifications are skipped (only in-app notifications are delivered).
|
||||
smtp_host: str = ""
|
||||
|
||||
@@ -442,12 +442,18 @@ def init_db():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
shared_with_email TEXT DEFAULT '',
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
# v5.x: migration — partage par groupes (colonne manquante sur DB existantes)
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 4) recents table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recents (
|
||||
|
||||
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
@@ -37,6 +38,7 @@ from app.routers import (
|
||||
webhooks,
|
||||
workspace,
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
@@ -45,7 +47,10 @@ from app.routers.github_routes import router as github_router
|
||||
from app.routers.imports import page_router as import_page_router
|
||||
from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -93,13 +98,22 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
task.cancel()
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
|
||||
for task in _tasks:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
@@ -108,9 +122,9 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.0.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
version="6.5.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -151,6 +165,10 @@ app.include_router(onboarding.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -231,12 +249,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
raised exceptions and route-miss 404s.
|
||||
"""
|
||||
status = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
if status == 404:
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
# Non-404: RFC7807 for /api/v2
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
||||
|
||||
@@ -666,6 +666,129 @@ def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(18, "v6.0.0: granular permissions (page/collection/property ACL + groups)")
|
||||
def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Granular permissions (page-level, collection-level,
|
||||
property-level access control + reusable user groups).
|
||||
|
||||
``user_groups`` — named groups scoped to a workspace.
|
||||
``group_members`` — users inside a group (N-ary join).
|
||||
``page_permissions`` — explicit grants for block-editor pages
|
||||
(``pages`` table). user_id XOR group_id.
|
||||
``collection_permissions`` — explicit grants for databases.
|
||||
``property_permissions`` — explicit viewer/editor grants per property.
|
||||
``permission_audit_log`` — immutable trail of every grant/revoke.
|
||||
``pages.permission_type`` / ``collections.permission_type`` — access
|
||||
mode: 'inherit' (default, follows the
|
||||
workspace/collection chain) | 'restricted'
|
||||
| 'private' (explicit grants only).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(group_id, user_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_user ON group_members(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit', -- explicit | group
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(page_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_page ON page_permissions(page_id, role)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_user ON page_permissions(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS collection_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_permissions(collection_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS property_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | editor
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, property_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_propp_prop ON property_permissions(property_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS permission_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_type TEXT NOT NULL, -- page | collection | property | group
|
||||
resource_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL, -- grant | revoke | type_change | group_create | group_delete | member_add | member_remove
|
||||
target_user_id INTEGER,
|
||||
target_group_id INTEGER,
|
||||
old_role TEXT,
|
||||
new_role TEXT,
|
||||
performed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_perm_audit_res "
|
||||
"ON permission_audit_log(resource_type, resource_id, created_at)"
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
@@ -673,6 +796,133 @@ def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
|
||||
@register(19, "v6.0.0: web clipper — extension devices & clips")
|
||||
def _migration_web_clipper(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Web Clipper: extension browser + capture."""
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL DEFAULT 'clipper',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT DEFAULT '',
|
||||
token_hash TEXT NOT NULL DEFAULT '',
|
||||
scopes TEXT NOT NULL DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_user ON extension_devices(user_id, revoked)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_device ON extension_devices(device_id)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
clip_type TEXT NOT NULL DEFAULT 'article',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
target_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
||||
target_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
title TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_user ON extension_clips(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_device ON extension_clips(device_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(20, "v6.3.0: api v2 — scopes, expires_at, audit, webhooks, idempotency")
|
||||
def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
"""v6.3.0 — API publique complète v2.
|
||||
|
||||
``api_tokens`` — adds ``scopes`` + ``expires_at`` (idempotent ALTER).
|
||||
``webhook_deliveries`` — delivery log for outbound webhooks (CRUD simple phase 1).
|
||||
``api_audit_log`` — immutable audit trail for v2 mutations.
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMP")
|
||||
# Backfill existing tokens without scopes
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
token_id INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL DEFAULT '',
|
||||
resource_id TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_user ON api_audit_log(user_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_resource ON api_audit_log(resource_type, resource_id)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
http_code INTEGER,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_wd_webhook ON webhook_deliveries(webhook_id, created_at)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
response_json TEXT NOT NULL DEFAULT '{}',
|
||||
status_code INTEGER NOT NULL DEFAULT 200,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_idemp_user ON idempotency_keys(user_id, created_at)")
|
||||
|
||||
|
||||
@register(21, "v6.4.0: webhooks prod — event, retry ledger")
|
||||
def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
"""v6.4.0 — Webhooks v2 production.
|
||||
|
||||
``webhook_deliveries`` gains ``event`` (which event was delivered) and
|
||||
``next_retry_at`` (epoch seconds; picked up by the retry scheduler).
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN next_retry_at REAL")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_wd_retry ON webhook_deliveries(status, next_retry_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(17, "v6.0.0: sync_version columns")
|
||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||
@@ -709,3 +959,27 @@ def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
|
||||
|
||||
@register(22, "v6.5.0: database row content pages")
|
||||
def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"""v6.5.0 — Synced blocks production: content for database rows.
|
||||
|
||||
A database row (``collection_pages``) gains a shadow ``pages`` row
|
||||
(``pages.collection_row_id``) that carries the Notion-style block
|
||||
content of the row: the full page editor, synced blocks, versions and
|
||||
realtime all work on it unchanged.
|
||||
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
"REFERENCES collection_pages(id) ON DELETE CASCADE"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -430,7 +431,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -498,7 +499,7 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -611,12 +612,21 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
|
||||
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
@@ -906,13 +916,13 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? ORDER BY updated_at DESC",
|
||||
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"ORDER BY updated_at DESC",
|
||||
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
@@ -968,6 +978,10 @@ async def add_favorite(request: Request, page_id: int):
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@@ -979,6 +993,10 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
@@ -1010,6 +1028,10 @@ async def publish_page(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
|
||||
|
||||
@@ -1022,6 +1044,10 @@ async def unpublish_page(request: Request, page_id: int):
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -1039,6 +1065,10 @@ async def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.restored", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@@ -1095,11 +1125,21 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import get_synced_block, update_synced_block
|
||||
from app.services.synced_blocks import (
|
||||
get_synced_block,
|
||||
page_ids_for_synced,
|
||||
sync_synced_blocks_in_page,
|
||||
update_synced_block,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
# reload the fresh content from the DB.
|
||||
for pid in page_ids_for_synced(sid):
|
||||
sync_synced_blocks_in_page(pid)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._propagate_synced(sid)
|
||||
return {"status": "ok"}
|
||||
@@ -1108,11 +1148,22 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
@router.delete("/api/synced-blocks/{sid}")
|
||||
async def delete_synced_block_api(request: Request, sid: int):
|
||||
"""Delete a synced block."""
|
||||
from app.services.synced_blocks import delete_synced_block, get_synced_block
|
||||
from app.services.synced_blocks import (
|
||||
delete_synced_block,
|
||||
get_synced_block,
|
||||
mark_synced_block_deleted,
|
||||
page_ids_for_synced,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
delete_synced_block(sid)
|
||||
mark_synced_block_deleted(sid, pids)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._broadcast_synced_to(pids, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -1362,8 +1413,14 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(page_id: int):
|
||||
async def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1377,10 +1434,22 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (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)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
@@ -1408,6 +1477,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
@@ -1425,6 +1497,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (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.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
@@ -1892,6 +1967,13 @@ async def move_page(request: Request, page_id: int):
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
if not uid:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — need at least edit access to trash.
|
||||
if not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1910,11 +1992,19 @@ async def view_page(request: Request, page_id: int):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time (fresh content
|
||||
# even when the stored cache is stale).
|
||||
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 ["", ""]
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import notifications as notif
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
@@ -116,6 +117,14 @@ async def add_comment(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
||||
mentioned_ids = notif.extract_mentions(text)
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"id": comment_id, "status": "created"}
|
||||
|
||||
|
||||
@@ -140,6 +149,11 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
"page", page_id, url, conn=conn,
|
||||
)
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
except Exception:
|
||||
pass
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@@ -161,10 +175,16 @@ async def update_comment(request: Request, comment_id: int):
|
||||
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body["body"].strip(), comment_id),
|
||||
)
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
if "resolved" in body and body.get("resolved") is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?",
|
||||
(1 if body["resolved"] else 0, comment_id))
|
||||
conn.commit()
|
||||
if body.get("resolved") and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
apply_auto_properties,
|
||||
@@ -33,6 +34,36 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict | None:
|
||||
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(s)
|
||||
return user if user and user.get("id") else None
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
|
||||
|
||||
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
||||
return [
|
||||
dict(r) for r in conn.execute(
|
||||
@@ -291,6 +322,13 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -456,10 +494,37 @@ async def get_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_view(page["collection_id"], _session_user(request))
|
||||
return dict(page)
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
async def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
this endpoint: it returns the shadow ``pages`` id whose full page
|
||||
editor carries the row's block content (synced blocks included).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
coll_id = row["collection_id"]
|
||||
# v6.0.0: granular collection permissions (same gate as the row itself).
|
||||
_require_view(coll_id, _session_user(request))
|
||||
from app.services.row_pages import ensure_row_page
|
||||
try:
|
||||
content_page_id = ensure_row_page(page_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Page not found") from None
|
||||
return {"page_id": content_page_id, "row_id": page_id}
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
async def update_page_api(request: Request, page_id: int):
|
||||
"""API: update a page's properties."""
|
||||
@@ -474,6 +539,8 @@ async def update_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
title = body.get("title", existing["title"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
@@ -511,6 +578,9 @@ async def update_page_api(request: Request, page_id: int):
|
||||
WHERE id=?""",
|
||||
(title, icon, cover_url, position, parent_id, property_values, page_id),
|
||||
)
|
||||
# v6.5.0: keep the row's content page title in sync (row → page).
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
@@ -520,6 +590,11 @@ async def update_page_api(request: Request, page_id: int):
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
await fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
})
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
@@ -537,6 +612,8 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -546,6 +623,10 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
await fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
})
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -601,7 +682,9 @@ async def list_property_types_api(request: Request):
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties for a collection."""
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
@@ -610,7 +693,14 @@ async def list_properties_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"properties": [dict(r) for r in rows]}
|
||||
props = [dict(r) for r in rows]
|
||||
# v6.0.0: property-level visibility — owners/editors see everything, other
|
||||
# users only the properties explicitly granted or left open.
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
props = [p for p in props if p["id"] in visible]
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
@@ -1133,8 +1223,15 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "view_type": view_type,
|
||||
await fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
})
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
|
||||
@@ -1204,8 +1301,15 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
existing["config_json"], max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "view_type": existing["view_type"],
|
||||
await fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
})
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
|
||||
@@ -1267,6 +1371,11 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@@ -1793,6 +1902,8 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -2349,6 +2460,8 @@ tr:hover td{{background:#222}}
|
||||
@router.get("/{collection_id}/api")
|
||||
async def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -2375,6 +2488,9 @@ async def get_collection_api(request: Request, collection_id: int):
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
async def create_page_api(request: Request, collection_id: int):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -2426,4 +2542,9 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
@@ -321,6 +321,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
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 ["", ""]
|
||||
@@ -608,6 +611,22 @@ page. If a page with the same title already exists, the offline copy is renamed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
@@ -869,7 +888,7 @@ async def list_workspace_projects(request: Request):
|
||||
builtin = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
||||
@@ -1065,7 +1084,9 @@ async def get_page_content(page_id: int):
|
||||
fmt = row["content_format"]
|
||||
if fmt == "file":
|
||||
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
||||
return {"content": row["content"] or "", "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):
|
||||
@@ -1169,7 +1190,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
|
||||
"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 "
|
||||
"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()
|
||||
@@ -1348,7 +1369,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
|
||||
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 "
|
||||
"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()
|
||||
@@ -1710,7 +1731,7 @@ async def list_workspaces(request: Request):
|
||||
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) as page_count "
|
||||
"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()
|
||||
@@ -2197,6 +2218,8 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
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:
|
||||
@@ -2397,6 +2420,31 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "synced":
|
||||
# v6.5.0: render synced block instances (resolved server-side).
|
||||
if b.get("_synced_deleted"):
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
||||
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
||||
'Deleted synced block</div>'
|
||||
)
|
||||
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(
|
||||
'<div style="margin:8px 0;padding-left:12px;'
|
||||
'border-left:3px solid var(--accent,#4c9aff);">'
|
||||
+ _render_blocks_public(inner, titles) + '</div>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
@@ -2414,9 +2462,11 @@ async def api_page_content(page_id: int):
|
||||
).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": row["content"],
|
||||
"content": resolve_content_json(row["content"], row["content_format"]),
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
@@ -2433,6 +2483,9 @@ async def api_rename_page(page_id: int, request: Request):
|
||||
"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}
|
||||
|
||||
|
||||
@@ -235,15 +235,24 @@ async def library_shared(
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Page ids the user shares toward others (nominal page_shares) or receives
|
||||
# (direct shares + group shares via group_members)
|
||||
with get_conn() as conn:
|
||||
made_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
try:
|
||||
recv_rows = conn.execute(
|
||||
"""SELECT DISTINCT s.page_id FROM page_shares s
|
||||
LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=?
|
||||
WHERE s.shared_with_user_id=? OR gm.user_id=?""",
|
||||
(uid, uid, uid),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
made_ids = {r[0] for r in made_rows}
|
||||
recv_ids = {r[0] for r in recv_rows}
|
||||
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""FlowDeck — v6.0.0 Granular permissions API (page/collection/property ACL).
|
||||
|
||||
Backend for the page-editor "Permissions" panel, database property visibility
|
||||
and user-group management. Grants are stored in ``page_permissions`` /
|
||||
``collection_permissions`` / ``property_permissions``; every mutation is logged
|
||||
into ``permission_audit_log`` for the admin audit view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["permissions"], prefix="/api/v2")
|
||||
|
||||
|
||||
PAGE_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
COLLECTION_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
PROPERTY_ROLES = ("viewer", "editor")
|
||||
PERMISSION_TYPES = ("inherit", "restricted", "private")
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _pm(request: Request) -> PermissionManager:
|
||||
return PermissionManager(_require_user(request)["id"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
try:
|
||||
return request.client.host if request.client else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _perm_list(conn, table: str, fk: str, resource_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.*,
|
||||
u.login AS user_login, u.full_name AS user_name,
|
||||
g.name AS group_name
|
||||
FROM {table} p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN user_groups g ON g.id = p.group_id
|
||||
WHERE p.{fk}=? ORDER BY p.id""",
|
||||
(resource_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if d.get("user_id"):
|
||||
d["name"] = d["user_name"] or d["user_login"] or f"User #{d['user_id']}"
|
||||
d["kind"] = "user"
|
||||
else:
|
||||
d["name"] = d["group_name"] or f"Group #{d['group_id']}"
|
||||
d["kind"] = "group"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _grant_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, body: dict, table: str, fk: str,
|
||||
allowed_roles: tuple[str, ...],
|
||||
extra_cols: dict | None = None) -> dict:
|
||||
user_id = body.get("user_id")
|
||||
group_id = body.get("group_id")
|
||||
role = (body.get("role") or "").strip()
|
||||
if role not in allowed_roles:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(allowed_roles)}")
|
||||
if not user_id and not group_id:
|
||||
raise HTTPException(400, "Provide either user_id or group_id")
|
||||
if user_id and not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id must be an integer")
|
||||
if group_id and not isinstance(group_id, int):
|
||||
raise HTTPException(400, "group_id must be an integer")
|
||||
actor = _require_user(request)["id"]
|
||||
with get_conn() as conn:
|
||||
if user_id:
|
||||
exists = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "User not found")
|
||||
if group_id:
|
||||
exists = conn.execute("SELECT id FROM user_groups WHERE id=?", (group_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "Group not found")
|
||||
existing = conn.execute(
|
||||
f"SELECT id, role FROM {table} WHERE {fk}=? AND user_id IS ? AND group_id IS ?",
|
||||
(resource_id, user_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(f"UPDATE {table} SET role=? WHERE id=?",
|
||||
(role, existing["id"]))
|
||||
old_role = existing["role"]
|
||||
perm_id = existing["id"]
|
||||
else:
|
||||
cols = [fk, "user_id", "group_id", "role", "granted_by"]
|
||||
vals: list = [resource_id, user_id, group_id, role, actor]
|
||||
for col, val in (extra_cols or {}).items():
|
||||
cols.append(col)
|
||||
vals.append(val)
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
tuple(vals),
|
||||
)
|
||||
perm_id = cur.lastrowid
|
||||
old_role = None
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "grant",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
old_role=old_role, new_role=role, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": perm_id, "role": role, "user_id": user_id, "group_id": group_id}
|
||||
|
||||
|
||||
def _revoke_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, fk: str, perm_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT user_id, group_id, role FROM {table} WHERE id=? AND {fk}=?",
|
||||
(perm_id, resource_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Permission not found")
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (perm_id,))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "revoke",
|
||||
target_user_id=row["user_id"], target_group_id=row["group_id"],
|
||||
old_role=row["role"], new_role=None, ip_address=_client_ip(request))
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
def _set_permission_type(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, body: dict) -> dict:
|
||||
ptype = (body.get("permission_type") or "").strip()
|
||||
if ptype not in PERMISSION_TYPES:
|
||||
raise HTTPException(400, f"permission_type must be one of {', '.join(PERMISSION_TYPES)}")
|
||||
with get_conn() as conn:
|
||||
conn.execute(f"UPDATE {table} SET permission_type=? WHERE id=?", (ptype, resource_id))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "type_change",
|
||||
new_role=ptype, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "permission_type": ptype}
|
||||
|
||||
|
||||
# ═══════════════ Page permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async def list_page_permissions(page_id: int, request: Request):
|
||||
"""List explicit page grants + the caller's effective role."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "page_permissions", "page_id", page_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_page_permission(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async def my_page_permission(page_id: int, request: Request):
|
||||
"""Effective role of the current user on a page (UI gating)."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
return {
|
||||
"role": pm.get_page_permission(page_id),
|
||||
"can_edit": pm.can_edit_page(page_id),
|
||||
"can_comment": pm.can_comment_page(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
}
|
||||
|
||||
|
||||
def _page_type(page_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "page", page_id, body,
|
||||
"page_permissions", "page_id", PAGE_ROLES)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions/batch")
|
||||
async def batch_page_permissions(page_id: int, request: Request):
|
||||
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
grants = body.get("grants") or []
|
||||
if not isinstance(grants, list) or not grants:
|
||||
raise HTTPException(400, "grants must be a non-empty list")
|
||||
results = []
|
||||
for g in grants:
|
||||
results.append(_grant_common(request, pm, "page", page_id, g,
|
||||
"page_permissions", "page_id", PAGE_ROLES))
|
||||
return {"status": "ok", "granted": results}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "page", page_id, "page_permissions", "page_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permission-type")
|
||||
async def set_page_permission_type(page_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
return _set_permission_type(request, pm, "page", page_id, "pages", await request.json())
|
||||
|
||||
|
||||
# ═══════════════ Collection permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async def list_collection_permissions(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "collection_permissions", "collection_id", collection_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_collection_permission(collection_id),
|
||||
"permission_type": _collection_type(collection_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _collection_type(collection_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
||||
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "collection", collection_id,
|
||||
"collection_permissions", "collection_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permission-type")
|
||||
async def set_collection_permission_type(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _set_permission_type(request, pm, "collection", collection_id,
|
||||
"collections", await request.json())
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async def visible_properties(collection_id: int, request: Request):
|
||||
"""Split property ids into visible / hidden for the current user."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
with get_conn() as conn:
|
||||
all_ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()]
|
||||
return {
|
||||
"visible": visible,
|
||||
"hidden": [pid for pid in all_ids if pid not in visible],
|
||||
"can_edit": pm.can_edit_collection(collection_id),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════ Property permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "property_permissions", "property_id", property_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine_view": pm.can_view_property(collection_id, property_id),
|
||||
"mine_edit": pm.can_edit_property(collection_id, property_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(property_id, collection_id),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, "Property not found")
|
||||
return _grant_common(request, pm, "property", property_id, body,
|
||||
"property_permissions", "property_id", PROPERTY_ROLES,
|
||||
extra_cols={"collection_id": collection_id})
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
return _revoke_common(request, pm, "property", property_id,
|
||||
"property_permissions", "property_id", perm_id)
|
||||
|
||||
|
||||
# ═══════════════ Groups ═══════════════
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(request: Request, workspace_id: int | None = None):
|
||||
user = _require_user(request)
|
||||
pm = PermissionManager(user["id"])
|
||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
ws_id = body.get("workspace_id")
|
||||
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
||||
created_by=pm.user_id)
|
||||
pm.log_permission_change("group", gid, "group_create",
|
||||
target_group_id=gid, new_role="",
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": gid}
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can edit groups")
|
||||
conn.execute(
|
||||
"UPDATE user_groups SET name=?, description=? WHERE id=?",
|
||||
(name, body.get("description") or "", group_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can delete groups")
|
||||
pm.delete_group(group_id)
|
||||
pm.log_permission_change("group", group_id, "group_delete",
|
||||
target_group_id=group_id, ip_address=_client_ip(request))
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async def list_group_members(group_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
user_id = body.get("user_id")
|
||||
if not user_id or not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.add_user_to_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_add",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.remove_user_from_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_remove",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════════ Users (access pickers) + audit ═══════════════
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
"""Workspace members (+ admins) for the grant pickers."""
|
||||
_require_user(request)
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
if workspace_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT u.id, u.login, u.full_name, u.email, u.avatar_color
|
||||
FROM users u
|
||||
LEFT JOIN workspace_members wm ON wm.user_id=u.id AND wm.workspace_id=?
|
||||
WHERE u.is_admin=1 OR wm.id IS NOT NULL
|
||||
ORDER BY u.login""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_color FROM users ORDER BY login"
|
||||
).fetchall()
|
||||
users = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if q and q not in (d["login"].lower(), d["full_name"].lower(),
|
||||
d["email"].lower()):
|
||||
continue
|
||||
users.append({"id": d["id"], "login": d["login"], "name": d["full_name"] or d["login"],
|
||||
"email": d["email"], "avatar_color": d["avatar_color"]})
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
is_admin = bool(user.get("is_admin"))
|
||||
limit = max(1, min(int(limit), 500))
|
||||
with get_conn() as conn:
|
||||
if not is_admin:
|
||||
owned = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=?", (uid,)
|
||||
).fetchall()
|
||||
if not owned:
|
||||
raise HTTPException(403, "Only a workspace owner or admin can view the audit log")
|
||||
rows = conn.execute(
|
||||
"""SELECT a.*, u.login AS actor_login
|
||||
FROM permission_audit_log a LEFT JOIN users u ON u.id=a.performed_by
|
||||
ORDER BY a.created_at DESC, a.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
@@ -40,6 +40,9 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
|
||||
token = authorization[7:] # strip "Bearer "
|
||||
if token == DEFAULT_TOKEN:
|
||||
from app.config import settings as _s
|
||||
if not _s.public_api_insecure_ok:
|
||||
raise HTTPException(401, "Default token disabled. Set PUBLIC_API_INSECURE_OK=true in dev or use a real Bearer token.")
|
||||
return token
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -17,6 +17,20 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.get("/api/realtime/stats")
|
||||
async def realtime_stats(request: Request):
|
||||
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||
|
||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||
"""
|
||||
user = SessionManager.decode_session(
|
||||
request.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
return {"error": "unauthorized"}
|
||||
return manager.stats()
|
||||
|
||||
|
||||
@router.websocket("/ws/pages/{page_id}")
|
||||
async def ws_page(websocket: WebSocket, page_id: int):
|
||||
await websocket.accept()
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -36,18 +37,23 @@ def _slugify(title: str) -> str:
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
"""Invite a user or email to a page."""
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
permission = body.get("permission", "view")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
if not target_user_id and not email:
|
||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||
if not target_user_id and not target_group_id and not email:
|
||||
raise HTTPException(400, "Provide user_id, group_id or email to share with.")
|
||||
|
||||
# Bridge share permission (view/comment/edit) → granular role
|
||||
# (viewer/commenter/editor) so page_permissions grants stay in sync.
|
||||
_SHARE_TO_ROLE = {"view": "viewer", "comment": "commenter", "edit": "editor"}
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
@@ -61,18 +67,29 @@ async def share_page(page_id: int, request: Request):
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
# Verify target group exists if group_id given
|
||||
if target_group_id:
|
||||
gtarget = conn.execute("SELECT id FROM user_groups WHERE id=?", (target_group_id,)).fetchone()
|
||||
if not gtarget:
|
||||
raise HTTPException(404, "Target group not found")
|
||||
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user or email) is already shared on this page.
|
||||
# target (user, group or email) is already shared on this page.
|
||||
target_row = None
|
||||
if target_user_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||
(page_id, target_user_id),
|
||||
).fetchone()
|
||||
elif target_group_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||
(page_id, target_group_id),
|
||||
).fetchone()
|
||||
elif email:
|
||||
target_row = conn.execute(
|
||||
"""SELECT id FROM page_shares
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL AND shared_with_group_id IS NULL""",
|
||||
(page_id, email.strip()),
|
||||
).fetchone()
|
||||
|
||||
@@ -85,29 +102,80 @@ async def share_page(page_id: int, request: Request):
|
||||
else:
|
||||
if not email:
|
||||
email = ""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_group_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, target_group_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback for DBs where the migration has not run yet
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
share_id = cur.lastrowid
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
# ── Mirror group shares into page_permissions so the ACL used by
|
||||
# PermissionManager (can_view/edit/comment) grants real access to
|
||||
# every group member. Best-effort: never break legacy page_shares.
|
||||
if target_group_id:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, target_group_id, _SHARE_TO_ROLE[permission], user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (page=%s group=%s)", page_id, target_group_id)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_group_id": target_group_id,
|
||||
"shared_with_email": email,
|
||||
"permission": permission,
|
||||
"status": "shared",
|
||||
}
|
||||
|
||||
|
||||
def _mirror_share_grant(conn, page_id: int, group_id: int, role: str, granted_by: int) -> None:
|
||||
"""Upsert a ``page_permissions`` grant mirroring a group ``page_shares`` row.
|
||||
|
||||
Keeps the granular ACL (used by ``PermissionManager``) in sync with what
|
||||
the share dialog shows, so invited groups get effective view/edit rights.
|
||||
"""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE page_permissions SET role=?, granted_by=? WHERE id=?",
|
||||
(role, granted_by, existing["id"]))
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO page_permissions (page_id, user_id, group_id, role, granted_by) "
|
||||
"VALUES (?, NULL, ?, ?, ?)",
|
||||
(page_id, group_id, role, granted_by),
|
||||
)
|
||||
|
||||
|
||||
def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
"""Remove the mirrored grant when a group share is updated away or deleted."""
|
||||
conn.execute(
|
||||
"DELETE FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
_require_auth(request)
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
@@ -117,7 +185,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
@@ -127,6 +195,18 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||
(permission, share_id),
|
||||
)
|
||||
# Keep the mirrored ACL grant in sync for group shares.
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, gid,
|
||||
{"view": "viewer", "comment": "commenter", "edit": "editor"}[permission],
|
||||
user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (share=%s)", share_id)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
||||
@@ -139,13 +219,22 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_revoke(conn, page_id, gid)
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions revoke failed (share=%s)", share_id)
|
||||
# If no more shares, unset is_shared
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||
@@ -167,14 +256,25 @@ async def list_shares(page_id: int, request: Request):
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url, g.name AS group_name
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
LEFT JOIN user_groups g ON s.shared_with_group_id = g.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -182,6 +282,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
{
|
||||
"id": r["id"],
|
||||
"shared_with_user_id": r["shared_with_user_id"],
|
||||
"shared_with_group_id": r["shared_with_group_id"] if "shared_with_group_id" in r.keys() else None,
|
||||
"shared_with_email": r["shared_with_email"],
|
||||
"permission": r["permission"],
|
||||
"created_at": r["created_at"],
|
||||
@@ -189,6 +290,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
"user_login": r["login"],
|
||||
"user_full_name": r["full_name"],
|
||||
"user_avatar_url": r["avatar_url"],
|
||||
"group_name": r["group_name"] if "group_name" in r.keys() else None,
|
||||
"kind": "group" if (("shared_with_group_id" in r.keys() and r["shared_with_group_id"]) or ("group_name" in r.keys() and r["group_name"])) else "user",
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
@@ -226,6 +329,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -252,6 +360,11 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync).
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync, Bearer v6.4.0).
|
||||
|
||||
Pairs with ``app/services/sync_engine.py``. All routes require an authenticated
|
||||
session (``flowdeck_session`` cookie).
|
||||
Auth: ``Authorization: Bearer <token>`` (scopes ``read`` for delta/status,
|
||||
``write`` for batch). The legacy ``flowdeck_session`` cookie is still accepted
|
||||
as a fallback so the installed PWA/service worker keeps syncing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.api_v2_helpers import get_bearer_user, has_scope
|
||||
from app.services.sync_engine import SyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,7 +22,23 @@ router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
|
||||
_engine = SyncEngine()
|
||||
|
||||
|
||||
def _user(request: Request) -> dict:
|
||||
def _user(request: Request, authorization: str | None = None,
|
||||
*, required_scope: str = "read") -> dict:
|
||||
"""Bearer-first auth with session-cookie fallback (offline.js compat)."""
|
||||
auth = authorization or request.headers.get("authorization") or ""
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
try:
|
||||
user = get_bearer_user(request, authorization)
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid or expired API token"
|
||||
) from None
|
||||
if not has_scope(user.get("_token_scopes"), required_scope):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Insufficient scope. Required: {required_scope}",
|
||||
)
|
||||
return user
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
@@ -32,9 +50,10 @@ async def sync_delta(
|
||||
request: Request,
|
||||
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
||||
workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""Pull server-side changes since `since` (for the given workspace)."""
|
||||
user = _user(request)
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
if workspace_id is None:
|
||||
raise HTTPException(status_code=400, detail="workspace_id is required")
|
||||
result = await _engine.get_delta(user["id"], since, workspace_id)
|
||||
@@ -44,9 +63,9 @@ async def sync_delta(
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
async def sync_batch(request: Request):
|
||||
async def sync_batch(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Apply a batch of offline mutations and return per-mutation results."""
|
||||
user = _user(request)
|
||||
user = _user(request, authorization, required_scope="write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -63,9 +82,10 @@ async def sync_batch(request: Request):
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None)):
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request)
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""FlowDeck — Web Clipper router (v6.0.0).
|
||||
|
||||
Endpoints:
|
||||
GET /api/v2/web-clipper/status
|
||||
POST /api/v2/web-clipper/auth/verify
|
||||
POST /api/v2/web-clipper/clip
|
||||
GET /api/v2/web-clipper/devices
|
||||
DELETE /api/v2/web-clipper/devices/{id}
|
||||
GET /extensions (HTML download page)
|
||||
|
||||
Auth: session cookie OR Bearer api_token OR Bearer extension device token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.web_clipper import (
|
||||
MAX_CLIP_BYTES,
|
||||
_check_rate_limit,
|
||||
create_page_from_clip,
|
||||
list_devices,
|
||||
log_clip,
|
||||
register_device,
|
||||
revoke_device,
|
||||
sanitize_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["web-clipper"])
|
||||
api_router = APIRouter(prefix="/api/v2/web-clipper", tags=["web-clipper"])
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _user_from_request(request: Request) -> dict | None:
|
||||
# 1) session cookie
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user:
|
||||
return user
|
||||
# 2) Authorization Bearer
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
return None
|
||||
th = _hash(token)
|
||||
with get_conn() as conn:
|
||||
# api_tokens (Settings → API tokens)
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# extension_devices
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# legacy user_tokens
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
return None
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
with get_conn() as conn:
|
||||
dev_cnt = conn.execute("SELECT COUNT(*) FROM extension_devices WHERE user_id=? AND revoked=0", (user["id"],)).fetchone()[0]
|
||||
clip_cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (user["id"],)).fetchone()[0]
|
||||
return {"authenticated": True, "user": {"id": user["id"], "login": user.get("login")}, "devices": dev_cnt, "clips": clip_cnt}
|
||||
|
||||
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
if not device_id:
|
||||
raise HTTPException(status_code=400, detail="device_id required")
|
||||
if len(device_id) > 128:
|
||||
raise HTTPException(status_code=400, detail="device_id too long")
|
||||
try:
|
||||
res = register_device(user["id"], device_id, device_name, extension_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
if res["existing"]:
|
||||
return {"status": "ok", "device_id": device_id, "existing": True, "message": "Device already registered"}
|
||||
return {"status": "ok", "device_id": device_id, "token": res["token"], "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
if clen:
|
||||
try:
|
||||
if int(clen) > MAX_CLIP_BYTES + 1024:
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
# Rate limit 50/hour per device
|
||||
if not _check_rate_limit(f"{user['id']}:{device_id}"):
|
||||
raise HTTPException(status_code=429, detail="Rate limit: max 50 clips/hour per device")
|
||||
|
||||
url = (body.get("url") or body.get("source_url") or "").strip()
|
||||
title = (body.get("title") or "").strip()
|
||||
content = body.get("content") or body.get("html") or ""
|
||||
clip_type = (body.get("content_type") or body.get("clip_type") or "article").strip().lower()
|
||||
if clip_type not in ("article", "selection", "bookmark", "screenshot"):
|
||||
clip_type = "article"
|
||||
|
||||
if not url and not title and not content:
|
||||
raise HTTPException(status_code=400, detail="url, title or content required")
|
||||
|
||||
# Validate url if present
|
||||
if url:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
# allow bare domain? reject javascript:
|
||||
if url.lower().startswith("javascript:") or url.lower().startswith("data:"):
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
|
||||
# Cap content bytes
|
||||
if content and len(content.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Content too large (max 10 MB)")
|
||||
|
||||
# Sanitize HTML content if present
|
||||
if content and "<" in content:
|
||||
# sanitize but keep structure for blocks converter
|
||||
content = sanitize_html(content)[: MAX_CLIP_BYTES]
|
||||
|
||||
# Prepare payload for service
|
||||
_img_b64 = body.get("image_base64") or body.get("screenshot") or ""
|
||||
if not _img_b64 and body.get("images"):
|
||||
try:
|
||||
_imgs = body.get("images")
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
"content": content,
|
||||
"content_type": clip_type,
|
||||
"selection_html": body.get("selection_html") or body.get("selection") or "",
|
||||
"image_base64": _img_b64,
|
||||
"tags": body.get("tags") or [],
|
||||
"target_workspace_id": body.get("target_workspace_id") or body.get("workspace_id"),
|
||||
"target_page_id": body.get("target_page_id") or body.get("parent_page_id"),
|
||||
"metadata": body.get("metadata") or {},
|
||||
}
|
||||
|
||||
try:
|
||||
result = create_page_from_clip(clip_data, user["id"])
|
||||
except Exception as e:
|
||||
logger.exception("clip creation failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create page") from None
|
||||
|
||||
# Log clip
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
sidebar = {}
|
||||
# Simple standalone page reusing base.html
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
user = _user_from_request(request)
|
||||
# Count for auth user
|
||||
devices = []
|
||||
clips = 0
|
||||
if user:
|
||||
try:
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
.ext-hero{{text-align:center;padding:28px 0 8px;}}
|
||||
.ext-hero h1{{font-size:30px;font-weight:800;margin:0 0 6px;}}
|
||||
.ext-hero p{{color:var(--text-dim);font-size:14px;max-width:560px;margin:0 auto;line-height:1.6;}}
|
||||
.ext-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:28px 0;}}
|
||||
.ext-card{{border:1px solid var(--border);border-radius:12px;padding:18px;background:var(--bg-card);}}
|
||||
.ext-card h3{{font-size:15px;margin:0 0 6px;display:flex;align-items:center;gap:8px;}}
|
||||
.ext-card p{{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin:0 0 10px;}}
|
||||
.ext-card a{{font-size:13px;color:var(--accent);text-decoration:none;}}
|
||||
.ext-card a:hover{{text-decoration:underline;}}
|
||||
.ext-section{{margin:28px 0;}}
|
||||
.ext-section h2{{font-size:18px;font-weight:700;margin:0 0 10px;}}
|
||||
.ext-steps{{counter-reset:step;list-style:none;padding:0;margin:0;}}
|
||||
.ext-steps li{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}}
|
||||
.ext-steps li::before{{counter-increment:step;content:counter(step);flex:0 0 26px;height:26px;display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:50%;font-size:12px;font-weight:600;}}
|
||||
.ext-dev-list{{margin-top:12px;}}
|
||||
.ext-dev-item{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--bg-tertiary);}}
|
||||
.ext-badge{{font-size:10px;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.14);color:#2ea043;font-weight:600;}}
|
||||
</style>
|
||||
<div class="ext-page">
|
||||
<div class="ext-hero">
|
||||
<h1>🧩 FlowDeck Web Clipper</h1>
|
||||
<p>Capture any web page — article, selection, bookmark or screenshot — directly into FlowDeck. Install the browser extension, connect it once, then clip in one click.</p>
|
||||
</div>
|
||||
<div class="ext-grid">
|
||||
<div class="ext-card">
|
||||
<h3>🟢 Chrome / Edge</h3>
|
||||
<p>Manifest V3 — Chrome 88+, Edge 88+.</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load unpacked in chrome://extensions</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>🟠 Firefox</h3>
|
||||
<p>Firefox 109+ (Manifest V2 compat).</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load temporary add-on</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>⌨️ Sans extension</h3>
|
||||
<p>API directe — <code>POST /api/v2/web-clipper/clip</code> avec Bearer token.</p>
|
||||
<a href="/help">Docs /help</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>How it works</h2>
|
||||
<ol class="ext-steps">
|
||||
<li>Install the extension (.zip) → enable in your browser.</li>
|
||||
<li>Open FlowDeck, go to <b>Settings → Extensions</b> and copy a Bearer token (or the clipper verifies via your session cookie).</li>
|
||||
<li>On any web page, click <b>📌 Clip to FlowDeck</b> (floating button, right-click selection, or extension popup).</li>
|
||||
<li>Choose type: Article (full), Selection, Bookmark or Screenshot — the page is created instantly in your workspace.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>Captures on this account</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);">{len(devices)} device(s) · {clips} clip(s) total</p>
|
||||
<div class="ext-dev-list">
|
||||
{"".join(f'<div class="ext-dev-item"><span><b>{d.get("device_name") or d.get("extension_name")}</b> <code style="font-size:11px;color:var(--text-dim);">{d.get("device_id")[:24]}</code></span><span><span class="ext-badge">{d.get("clips_count",0)} clips</span> <span style="font-size:11px;color:var(--text-dim);">{d.get("last_clip_at") or ""}</span></span></div>' for d in devices[:10]) or '<p style="font-size:13px;color:var(--text-dim);">No devices yet — clip your first page from the extension to appear here.</p>'}
|
||||
</div>
|
||||
<p style="margin-top:10px;"><a href="/accounts/settings" style="font-size:13px;color:var(--accent);">Manage in Settings → Extensions</a></p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return HTMLResponse(block_tpl.render(**sidebar, request=request, page_title="Extensions", title_prefix="Extensions", page_icon="🧩", content_html=content_html))
|
||||
@@ -130,6 +130,10 @@ async def add_comment(request: Request, page_id: int):
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@@ -139,11 +143,17 @@ async def update_comment(request: Request, comment_id: int):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id, resolved FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if b is not None:
|
||||
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||
if resolved is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||
conn.commit()
|
||||
if resolved and row and not int(row["resolved"] or 0):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@@ -206,6 +216,10 @@ async def add_favorite(request: Request):
|
||||
(uid, page_id, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@@ -449,6 +463,10 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -475,6 +493,10 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
@@ -259,6 +259,17 @@ class AgentEngine:
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""FlowDeck — helpers for API v2 (v6.3.0).
|
||||
|
||||
Pagination, ISO-8601, RFC7807 errors, hierarchical scopes, Bearer auth.
|
||||
No duplication: thin wrappers over existing services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def to_iso8601(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
# SQLite stores "YYYY-MM-DD HH:MM:SS" or with T; convert to UTC Z
|
||||
try:
|
||||
# try with seconds
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"):
|
||||
try:
|
||||
dt = datetime.strptime(value[:19], fmt[:8] if "." in value else fmt)
|
||||
# SQLite has no tz => assume UTC
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
except ValueError:
|
||||
continue
|
||||
# fallback: if already ISO with T/Z, return as-is
|
||||
if "T" in value:
|
||||
return value
|
||||
return value
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at", "created_at_ts", "last_login", "joined_at", "accessed_at", "fired_at", "start_date", "end_date", "logged_at", "last_seen_at", "last_used_at", "verified_at", "last_login_at")) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
d = dict(row)
|
||||
for k in list(d.keys()):
|
||||
if k in iso_fields and d[k]:
|
||||
iso = to_iso8601(str(d[k]))
|
||||
if iso:
|
||||
d[k] = iso
|
||||
# parse *_json columns
|
||||
if k.endswith("_json") and isinstance(d[k], str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_pagination(request: Request, default_limit: int = 30, max_limit: int = 100) -> tuple[int, int]:
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", str(default_limit)))
|
||||
except ValueError:
|
||||
limit = default_limit
|
||||
try:
|
||||
offset = int(request.query_params.get("offset", "0"))
|
||||
except ValueError:
|
||||
offset = 0
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
def paginate_headers(total: int) -> dict[str, str]:
|
||||
return {"X-Total-Count": str(total)}
|
||||
|
||||
# ── Scopes (hierarchical: read < write < admin) ──────────────────────────
|
||||
|
||||
SCOPE_RANK = {"read": 1, "write": 2, "admin": 3}
|
||||
VALID_SCOPES = set(SCOPE_RANK.keys())
|
||||
|
||||
def normalize_scopes(raw: str | None) -> set[str]:
|
||||
if not raw:
|
||||
return set()
|
||||
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
|
||||
return {p for p in parts if p in VALID_SCOPES}
|
||||
|
||||
def has_scope(token_scopes: str | None, required: str) -> bool:
|
||||
req_rank = SCOPE_RANK.get(required, 99)
|
||||
# token with higher rank satisfies lower requirement
|
||||
# admin => write => read
|
||||
token_set = normalize_scopes(token_scopes)
|
||||
if not token_set:
|
||||
return False
|
||||
# effective rank = max rank among token scopes
|
||||
eff = max((SCOPE_RANK.get(s, 0) for s in token_set), default=0)
|
||||
return eff >= req_rank
|
||||
|
||||
def validate_scopes_input(scopes_raw: str | None) -> str:
|
||||
if not scopes_raw:
|
||||
return "read"
|
||||
parts = [p.strip().lower() for p in scopes_raw.split(",") if p.strip()]
|
||||
for p in parts:
|
||||
if p not in VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {p}. Valid: read, write, admin")
|
||||
if not parts:
|
||||
return "read"
|
||||
# dedup preserve order
|
||||
seen = []
|
||||
for p in parts:
|
||||
if p not in seen:
|
||||
seen.append(p)
|
||||
return ",".join(seen)
|
||||
|
||||
# ── Bearer auth (unified) ─────────────────────────────────────────────────
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def resolve_bearer_token(token: str) -> dict | None:
|
||||
"""Resolve Bearer token to user dict. Returns None if invalid/expired/revoked.
|
||||
Supports api_tokens (hashed), extension_devices (hashed), and legacy user_tokens (plain).
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
# dev-only fallback
|
||||
if token == "fd-public-key":
|
||||
if not settings.public_api_insecure_ok:
|
||||
return None
|
||||
# return a synthetic admin-like user? Use first admin or id 1
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
return None
|
||||
th = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
# 1) api_tokens
|
||||
row = conn.execute("SELECT id, user_id, scopes, expires_at, revoked FROM api_tokens WHERE token_hash=?", (th,)).fetchone()
|
||||
if row:
|
||||
if row["revoked"]:
|
||||
return None
|
||||
exp = row["expires_at"]
|
||||
if exp:
|
||||
try:
|
||||
# compare as timestamp; SQLite format "YYYY-MM-DD HH:MM:SS"
|
||||
# parse to epoch
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if "T" in str(exp) else datetime.strptime(str(exp)[:19], "%Y-%m-%d %H:%M:%S")
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = row["id"]
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
# touch last_used_at best-effort
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
# 2) extension_devices
|
||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
# 3) legacy user_tokens (plain storage)
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
return None
|
||||
|
||||
def get_bearer_user(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
# Prefer explicit Authorization header, fallback to lowercase
|
||||
auth = authorization or request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if not auth or not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="API token required. Use Authorization: Bearer <token>")
|
||||
token = auth[7:].strip()
|
||||
user = resolve_bearer_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired API token")
|
||||
return user
|
||||
|
||||
def require_scope(required: str):
|
||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
user = get_bearer_user(request, authorization)
|
||||
scopes = user.get("_token_scopes") or "read"
|
||||
if not has_scope(scopes, required):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
||||
return user
|
||||
return _dep
|
||||
|
||||
# ── RFC 7807 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def problem_response(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
title_map = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
409: "Conflict",
|
||||
422: "Unprocessable Entity",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
status = exc.status_code
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
body = {
|
||||
"type": f"https://flowdeck/api/errors/{status}",
|
||||
"title": title_map.get(status, "Error"),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"instance": str(request.url.path),
|
||||
}
|
||||
return JSONResponse(status_code=status, content=body, media_type="application/problem+json")
|
||||
|
||||
# ── Audit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str | int = "", detail: str = "", request: Request | None = None) -> None:
|
||||
try:
|
||||
ip = ""
|
||||
if request and request.client:
|
||||
ip = request.client.host or ""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO api_audit_log (user_id, token_id, action, resource_type, resource_id, ip_address, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user.get("id"), user.get("_token_id"), action, resource_type, str(resource_id), ip, detail[:1000]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
_v2_rate_store: dict[str, tuple[float, int]] = {}
|
||||
def check_v2_rate_limit(token_hash: str | None, ip: str) -> bool:
|
||||
"""Return True if allowed, False if 429. Uses api_v2_rate_limit_per_token."""
|
||||
key = token_hash or f"ip:{ip}"
|
||||
now = time.time()
|
||||
window = 60.0
|
||||
max_req = settings.api_v2_rate_limit_per_token
|
||||
start, count = _v2_rate_store.get(key, (now, 0))
|
||||
if now - start > window:
|
||||
_v2_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= max_req:
|
||||
return False
|
||||
_v2_rate_store[key] = (start, count + 1)
|
||||
return True
|
||||
|
||||
# ── Idempotency ───────────────────────────────────────────────────────────
|
||||
|
||||
def check_idempotency(request: Request, user_id: int) -> dict | None:
|
||||
key = request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key")
|
||||
if not key:
|
||||
return None
|
||||
key = key.strip()[:200]
|
||||
if not key:
|
||||
return None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT response_json, status_code FROM idempotency_keys WHERE key=? AND user_id=?", (key, user_id)).fetchone()
|
||||
if row:
|
||||
try:
|
||||
data = json.loads(row["response_json"])
|
||||
return {"data": data, "status": row["status_code"], "key": key}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200) -> None:
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO idempotency_keys (key, user_id, response_json, status_code) VALUES (?, ?, ?, ?)",
|
||||
(key.strip()[:200], user_id, json.dumps(data), status_code),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -283,6 +283,20 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
detail = "; ".join(results)
|
||||
_save_run(automation_id, trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
# v6.4.0: emit automation.fired (goes through fire_event → outbound
|
||||
# webhooks, but NOT back through automations to avoid recursion).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": automation_id,
|
||||
"name": auto["name"],
|
||||
"trigger": trigger_source,
|
||||
"collection_id": ctx.get("collection_id"),
|
||||
"page_id": ctx.get("page_id"),
|
||||
"detail": detail,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("automation.fired webhook dispatch failed")
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001 — record every failure in history
|
||||
logger.warning("Automation %s failed: %s", automation_id, exc)
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"""FlowDeck — Agent permission guard (v4.10.0).
|
||||
"""FlowDeck — Permission manager: workspace roles + granular ACL (v6.0.0).
|
||||
|
||||
The agent always acts with *at most* the permissions of the invoking user
|
||||
(Notion Agent principle). This manager resolves the user's role in the active
|
||||
workspace and gates tool execution before any write reaches the database.
|
||||
Two layers:
|
||||
|
||||
1. **Workspace roles** (v4.10.0, agent guard): every user has a single role in
|
||||
each workspace (owner > owner-membership > editor > commenter > viewer).
|
||||
The FlowDeck Agent always acts with *at most* the permissions of the
|
||||
invoking user (Notion Agent principle).
|
||||
|
||||
2. **Granular permissions** (v6.0.0): explicit page / collection / property
|
||||
grants plus reusable user groups. Resolution follows the least-privilege
|
||||
rule — an explicit grant on a resource overrides the inherited chain
|
||||
(page → collection → workspace), while ``restricted`` / ``private``
|
||||
resources deny access unless a grant (or the workspace owner / admin)
|
||||
applies.
|
||||
|
||||
Resolution results are cached for 60 s to keep the hot paths (sidebar, view
|
||||
rendering, route guards) < 10 ms per check; ``PermissionManager.invalidate()``
|
||||
drops the cache after any grant/revoke/type change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -19,6 +34,12 @@ READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
|
||||
WRITE_ROLES = {"editor", "admin", "owner"}
|
||||
DESTRUCTIVE_ROLES = {"admin", "owner"}
|
||||
|
||||
# Granular resource roles (ranked, least → most privileged).
|
||||
_GRANULAR_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
_ROLE_RANK = {role: i for i, role in enumerate(_GRANULAR_ROLES)}
|
||||
_PROPERTY_ROLES = ("viewer", "editor")
|
||||
_PROPERTY_RANK = {"viewer": 0, "editor": 1}
|
||||
|
||||
# Tools that mutate state and therefore require at least an editor role.
|
||||
WRITE_TOOLS = {
|
||||
"create_collection", "create_view", "create_page", "update_page",
|
||||
@@ -35,10 +56,27 @@ DESTRUCTIVE_TOOLS = {
|
||||
|
||||
|
||||
class PermissionManager:
|
||||
"""Resolves workspace role and gates agent tool calls."""
|
||||
"""Resolves workspace role and gates agent + granular ACL checks."""
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
def __init__(self, user_id: int, is_admin: bool = False):
|
||||
self.user_id = user_id
|
||||
self._is_admin_override = bool(is_admin)
|
||||
self._cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
# ── Cache helpers ──
|
||||
|
||||
def _cached(self, key: str, ttl: float, fn):
|
||||
now = time.monotonic()
|
||||
hit = self._cache.get(key)
|
||||
if hit and now - hit[0] < ttl:
|
||||
return hit[1]
|
||||
val = fn()
|
||||
self._cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drop the resolution cache after a grant/revoke/type change."""
|
||||
self._cache.clear()
|
||||
|
||||
# ── Role resolution ──
|
||||
|
||||
@@ -100,3 +138,315 @@ class PermissionManager:
|
||||
# A viewer can always read; editor can read+write.
|
||||
if role not in READ_ROLES:
|
||||
raise HTTPException(status_code=403, detail="User has no access to this workspace")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Granular permissions (v6.0.0)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _is_admin(self, conn) -> bool:
|
||||
if self._is_admin_override:
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (self.user_id,)
|
||||
).fetchone()
|
||||
return bool(row and row["is_admin"])
|
||||
|
||||
def _owns_workspace(self, conn, workspace_id: int | None) -> bool:
|
||||
if workspace_id is None:
|
||||
# No workspace → single-user semantics: the actor is the owner.
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
def user_group_ids(self, conn) -> list[int]:
|
||||
return [
|
||||
r["group_id"]
|
||||
for r in conn.execute(
|
||||
"SELECT group_id FROM group_members WHERE user_id=?", (self.user_id,)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
def _explicit_grant_role(self, conn, table: str, fk: str, resource_id: int,
|
||||
role_rank: dict[str, int] | None = None) -> str | None:
|
||||
"""Most-privileged explicit role on ``table`` for the user / groups."""
|
||||
rank = role_rank or _ROLE_RANK
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(resource_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? AND user_id=?",
|
||||
(resource_id, self.user_id),
|
||||
).fetchall()
|
||||
best = max((rank.get(r["role"], -1) for r in rows), default=-1)
|
||||
if best < 0:
|
||||
return None
|
||||
rev = {rank[k]: k for k in rank}
|
||||
return rev[best]
|
||||
|
||||
# ── Page-level ──
|
||||
|
||||
def get_page_permission(self, page_id: int) -> str | None:
|
||||
"""Effective page role for ``self.user_id`` (least privilege).
|
||||
|
||||
Chain: explicit page grant > explicit collection grant > workspace
|
||||
role. ``restricted`` / ``private`` pages ignore the inherited chain.
|
||||
Returns ``None`` when the user must not see the page at all.
|
||||
"""
|
||||
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT permission_type, workspace_id, collection_id FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, page["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "page_permissions", "page_id", page_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = page["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
if page["collection_id"]:
|
||||
coll_role = self._collection_role(conn, page["collection_id"])
|
||||
if coll_role:
|
||||
return coll_role
|
||||
return self.role_in_workspace(page["workspace_id"])
|
||||
return self._cached(f"page:{page_id}", 60, _resolve)
|
||||
|
||||
def can_view_page(self, page_id: int) -> bool:
|
||||
return self.get_page_permission(page_id) is not None
|
||||
|
||||
def can_edit_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_comment_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["commenter"])
|
||||
|
||||
def can_manage_page_permissions(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Collection-level ──
|
||||
|
||||
def _collection_role(self, conn, collection_id: int) -> str | None:
|
||||
coll = conn.execute(
|
||||
"SELECT permission_type, workspace_id FROM collections WHERE id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, coll["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "collection_permissions", "collection_id", collection_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = coll["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
return self.role_in_workspace(coll["workspace_id"])
|
||||
|
||||
def get_collection_permission(self, collection_id: int) -> str | None:
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
return self._collection_role(conn, collection_id)
|
||||
return self._cached(f"collection:{collection_id}", 60, _resolve)
|
||||
|
||||
def can_view_collection(self, collection_id: int) -> bool:
|
||||
return self.get_collection_permission(collection_id) is not None
|
||||
|
||||
def can_edit_collection(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_manage_collection_permissions(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Property-level ──
|
||||
|
||||
def _property_grants_exist(self, conn, property_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM property_permissions WHERE property_id=? LIMIT 1",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def _has_property_grant(self, conn, property_id: int, min_rank: int) -> bool:
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM property_permissions WHERE property_id=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(property_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT role FROM property_permissions WHERE property_id=? AND user_id=?",
|
||||
(property_id, self.user_id),
|
||||
).fetchall()
|
||||
return any(_PROPERTY_RANK.get(r["role"], -1) >= min_rank for r in rows)
|
||||
|
||||
def can_view_property(self, collection_id: int, property_id: int) -> bool:
|
||||
"""A property is visible unless it carries explicit grants excluding
|
||||
the user; without any grant it inherits from the collection. Collection
|
||||
owners/admins always see every property."""
|
||||
if not self.can_view_collection(collection_id):
|
||||
return False
|
||||
return self._cached(
|
||||
f"prop:{property_id}", 60, lambda: self._property_visible(collection_id, property_id)
|
||||
)
|
||||
|
||||
def _collection_workspace_id(self, conn, collection_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["workspace_id"] if row else None
|
||||
|
||||
def _property_visible(self, collection_id: int, property_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["viewer"])
|
||||
|
||||
def can_edit_property(self, collection_id: int, property_id: int) -> bool:
|
||||
if not self.can_edit_collection(collection_id):
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["editor"])
|
||||
|
||||
def get_visible_properties(self, collection_id: int) -> list[int]:
|
||||
def _resolve() -> list[int]:
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [p["id"] for p in props if self.can_view_property(collection_id, p["id"])]
|
||||
return self._cached(f"visible_props:{collection_id}", 60, _resolve)
|
||||
|
||||
# ── Groups ──
|
||||
|
||||
def is_workspace_admin(self, workspace_id: int | None) -> bool:
|
||||
with get_conn() as conn:
|
||||
return self._is_admin(conn) or self._owns_workspace(conn, workspace_id)
|
||||
|
||||
def create_group(self, workspace_id: int | None, name: str,
|
||||
description: str = "", created_by: int | None = None) -> int:
|
||||
if not self.is_workspace_admin(workspace_id):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can create groups")
|
||||
if not name.strip():
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
dupe = conn.execute(
|
||||
"SELECT id FROM user_groups WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, name.strip()),
|
||||
).fetchone()
|
||||
if dupe:
|
||||
raise HTTPException(400, "A group with this name already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO user_groups (workspace_id, name, description, created_by) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(workspace_id, name.strip(), description or "", created_by),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def add_user_to_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
group = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not group:
|
||||
raise HTTPException(404, "Group not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def remove_user_from_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM group_members WHERE group_id=? AND user_id=?",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_group(self, group_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_groups WHERE id=?", (group_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_groups_for_workspace(self, workspace_id: int | None) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT g.id, g.name, g.description, g.created_by, g.created_at,
|
||||
(SELECT COUNT(*) FROM group_members m WHERE m.group_id=g.id) AS member_count
|
||||
FROM user_groups g WHERE g.workspace_id IS ? ORDER BY g.name""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_group_members(self, group_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.email, m.joined_at
|
||||
FROM group_members m JOIN users u ON u.id=m.user_id
|
||||
WHERE m.group_id=? ORDER BY u.login""",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Audit log ──
|
||||
|
||||
def log_permission_change(self, resource_type: str, resource_id: int, action: str,
|
||||
target_user_id: int | None = None,
|
||||
target_group_id: int | None = None,
|
||||
old_role: str | None = None,
|
||||
new_role: str | None = None,
|
||||
ip_address: str = "") -> None:
|
||||
"""Write one immutable audit row for a permission change."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO permission_audit_log
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, performed_by, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, self.user_id, ip_address),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # audit must never break the caller
|
||||
logger.warning("permission audit log failed: %s", exc)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — v6.4.0 Realtime: résolution de conflits au-delà du last-write-wins.
|
||||
|
||||
Le LWW par bloc (v5.13.0) écrase intégralement le bloc du dernier arrivé : si deux
|
||||
utilisateurs tapent dans le *même* bloc, la saisie du premier est perdue. Ce module
|
||||
implémente un vrai *merge à trois versions* (diff3-lite) :
|
||||
|
||||
base = l'état du bloc dont le client dérive sa saisie (envoyé avec l'op)
|
||||
current = l'état actuel du bloc côté serveur (déjà mis à jour par d'autres)
|
||||
incoming = la nouvelle proposition du client
|
||||
|
||||
Règle champ-par-champ :
|
||||
* incoming == base → le client n'a pas touché ce champ → on garde current
|
||||
* current == base → le serveur n'a pas touché ce champ → on garde incoming
|
||||
* current == incoming → les deux ont fait la même chose → sans conflit
|
||||
* sinon (conflit)
|
||||
- champ texte (str) : merge de caractères. Les régions modifiées qui ne se
|
||||
chevauchent pas sont *toutes conservées* (les deux saisies survivent) ;
|
||||
chevauchement réel → LWW sur ce champ + drapeau de conflit.
|
||||
- autre type (bool, nombre…) : LWW sur ce champ + drapeau de conflit.
|
||||
|
||||
Toutes les fonctions sont pures et testables sans WebSocket ni base de données.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["merge_text_3way", "merge_block_3way", "changed_region"]
|
||||
|
||||
|
||||
def changed_region(base: str, other: str) -> tuple[int, int, str] | None:
|
||||
"""Région de `base` remplacée par `other` (trim préfixe/suffixe commun).
|
||||
|
||||
Retourne ``(start, end, replacement)`` tel que ``base[:start] + replacement +
|
||||
base[end:] == other``, ou ``None`` si ``other == base`` (aucun changement).
|
||||
"""
|
||||
if base == other:
|
||||
return None
|
||||
minlen = min(len(base), len(other))
|
||||
prefix = 0
|
||||
while prefix < minlen and base[prefix] == other[prefix]:
|
||||
prefix += 1
|
||||
suffix = 0
|
||||
# ne jamais chevaucher le préfixe déjà consommé
|
||||
while (suffix < len(base) - prefix and suffix < len(other) - prefix
|
||||
and base[len(base) - 1 - suffix] == other[len(other) - 1 - suffix]):
|
||||
suffix += 1
|
||||
return prefix, len(base) - suffix, other[prefix:len(other) - suffix]
|
||||
|
||||
|
||||
def merge_text_3way(base: str, current: str, incoming: str) -> tuple[str, bool]:
|
||||
"""Merge à trois versions d'une chaîne. Retourne ``(texte, conflit)``.
|
||||
|
||||
Les éditions qui ne se chevauchent pas sont toutes les deux conservées ;
|
||||
un chevauchement réel retombe en LWW (``incoming`` gagne) et signale le conflit.
|
||||
"""
|
||||
if current == incoming:
|
||||
return current, False
|
||||
if current == base:
|
||||
return incoming, False
|
||||
if incoming == base:
|
||||
return current, False
|
||||
|
||||
rc = changed_region(base, current)
|
||||
ri = changed_region(base, incoming)
|
||||
if rc is None:
|
||||
return incoming, False
|
||||
if ri is None:
|
||||
return current, False
|
||||
|
||||
c_start, c_end, c_text = rc
|
||||
i_start, i_end, i_text = ri
|
||||
|
||||
# Régions disjointes (ou juste adjacentes) → appliquer les deux sur base.
|
||||
if c_end <= i_start or i_end <= c_start:
|
||||
edits = sorted([(c_start, c_end, c_text), (i_start, i_end, i_text)],
|
||||
key=lambda e: e[0])
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
for start, end, text in edits:
|
||||
if start < pos:
|
||||
continue # sécurité: ne jamais réappliquer par-dessus
|
||||
out.append(base[pos:start])
|
||||
out.append(text)
|
||||
pos = end
|
||||
out.append(base[pos:])
|
||||
return "".join(out), False
|
||||
|
||||
# Chevauchement réel → LWW sur ce champ, conflit signalé.
|
||||
return incoming, True
|
||||
|
||||
|
||||
def _scalar_conflict(base: Any, current: Any, incoming: Any) -> tuple[Any, bool]:
|
||||
"""Conflit sur un champ non-texte : LWW (incoming gagne)."""
|
||||
if current == incoming:
|
||||
return current, False
|
||||
if current == base:
|
||||
return incoming, False
|
||||
if incoming == base:
|
||||
return current, False
|
||||
return incoming, True
|
||||
|
||||
|
||||
def merge_block_3way(base_blk: Any, current_blk: Any,
|
||||
incoming_blk: Any) -> tuple[dict, list[str]]:
|
||||
"""Merge à trois versions d'un bloc entier.
|
||||
|
||||
Retourne ``(bloc fusionné, champs en conflit)``. Ne lève jamais d'exception :
|
||||
une entrée non-dict retombe en LWW (``incoming``) avec conflit signalé sur
|
||||
``__block__`` pour que l'appelant puisse journaliser.
|
||||
"""
|
||||
if not isinstance(base_blk, dict):
|
||||
base_blk = {}
|
||||
if not isinstance(current_blk, dict):
|
||||
current_blk = {}
|
||||
if not isinstance(incoming_blk, dict):
|
||||
# proposition invalide → on garde l'état serveur
|
||||
return dict(current_blk), ["__block__"]
|
||||
|
||||
keys = set(base_blk) | set(current_blk) | set(incoming_blk)
|
||||
merged: dict[str, Any] = {}
|
||||
conflicts: list[str] = []
|
||||
|
||||
for key in keys:
|
||||
b = base_blk.get(key)
|
||||
c = current_blk.get(key)
|
||||
i = incoming_blk.get(key)
|
||||
|
||||
if i == b:
|
||||
# le client n'a pas modifié ce champ → valeur serveur. Si le serveur
|
||||
# a *supprimé* le champ (absent de current) alors la suppression doit
|
||||
# gagner : on n'insère pas de clé fantôme value=None.
|
||||
if key not in current_blk:
|
||||
continue
|
||||
merged[key] = c
|
||||
elif c == b:
|
||||
# le serveur n'a pas modifié ce champ → valeur client
|
||||
merged[key] = i
|
||||
elif c == i:
|
||||
merged[key] = c
|
||||
else:
|
||||
# les deux ont changé, différemment
|
||||
if isinstance(b, str) and isinstance(c, str) and isinstance(i, str):
|
||||
text, conflict = merge_text_3way(b, c, i)
|
||||
merged[key] = text
|
||||
if conflict:
|
||||
conflicts.append(key)
|
||||
else:
|
||||
value, conflict = _scalar_conflict(b, c, i)
|
||||
merged[key] = value
|
||||
if conflict:
|
||||
conflicts.append(key)
|
||||
|
||||
# Un champ supprimé par le serveur et absent de la proposition client doit
|
||||
# rester supprimé (pas de réapparition d'une valeur None fantôme).
|
||||
merged = {k: v for k, v in merged.items()
|
||||
if not (v is None and k not in incoming_blk)}
|
||||
|
||||
if "id" not in merged:
|
||||
# garantir l'identité du bloc même si base était vide
|
||||
ident = incoming_blk.get("id") or current_blk.get("id")
|
||||
if ident:
|
||||
merged["id"] = ident
|
||||
|
||||
return merged, conflicts
|
||||
@@ -1,5 +1,23 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge des opérations de blocs (last-write-wins par bloc) + version de page.
|
||||
"""FlowDeck — v6.4.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge à trois versions des opérations de blocs (au-delà du last-write-wins) +
|
||||
version de page.
|
||||
|
||||
Améliorations « production » par rapport à v5.13.0 :
|
||||
|
||||
* **Conflits** — les mises à jour de bloc embarquent la ``base`` dont dérive la
|
||||
saisie du client ; le serveur fait un merge 3-voix champ-par-champ + texte
|
||||
(voir ``realtime_merge``) au lieu d'écraser le bloc entier. Les deux saisies
|
||||
disjointes survivent, les chevauchements réels retombent en LWW *par champ*
|
||||
avec drapeau de conflit renvoyé au client.
|
||||
* **Échelle** — chaque connexion possède une file sortante + une tâche writer
|
||||
dédiée ; le broadcast devient non bloquant (un client lent ne bloque plus la
|
||||
room), les mises à jour de curseur se coalescent (une seule par flush), et un
|
||||
client trop lent (file pleine) est déconnecté proprement (4413).
|
||||
* **Anti-flood** — budget d'opérations par connexion (fenêtre glissante).
|
||||
* **Fuites corrigées** — une room 4404 n'est plus enregistrée ; ``room_state``
|
||||
ne crée plus d'objet None ; les rooms vides sont évacuées.
|
||||
* **Observabilité** — compteurs (rooms, conns, ops, merges, conflits, déconnexions
|
||||
lentes) exposés par ``stats()``.
|
||||
|
||||
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
|
||||
avec debounce. Fallback polling côté client si le WS est indisponible.
|
||||
@@ -9,16 +27,25 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
|
||||
|
||||
# File sortante maximale par connexion au-delà de laquelle le client est
|
||||
# considéré comme trop lent et déconnecté (évite qu'une room entière stagne).
|
||||
MAX_OUT_QUEUE = 512
|
||||
# Nombre maximal d'opérations acceptées par connexion et par fenêtre (anti-flood).
|
||||
OP_WINDOW_SECONDS = 10.0
|
||||
OP_WINDOW_MAX = 400
|
||||
|
||||
|
||||
def color_for(uid: int) -> str:
|
||||
return COLORS[(uid or 0) % len(COLORS)]
|
||||
@@ -48,7 +75,11 @@ def ensure_block_ids(blocks: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc.
|
||||
|
||||
Conservé pour la compatibilité : tests et chemins sans ``base`` continuent
|
||||
de fonctionner. Le merge 3-voix vit dans ``RealtimeManager._apply``.
|
||||
"""
|
||||
t = op.get("type")
|
||||
if t == "insert":
|
||||
blk = op.get("block") or {}
|
||||
@@ -90,9 +121,40 @@ def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
class RTConn:
|
||||
"""Une connexion WS : file sortante + tâche writer dédiée.
|
||||
|
||||
Le broadcast ne fait que ``put_nowait`` dans la file ; c'est la tâche writer
|
||||
qui consomme et écrit sur le socket. Un client lent n'empêche donc jamais
|
||||
les autres membres de la room de recevoir les messages.
|
||||
"""
|
||||
__slots__ = ("ws", "user", "page_id", "out_q", "writer", "closed",
|
||||
"_ops_count", "_ops_window_start", "created_at")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
self.out_q: asyncio.Queue = asyncio.Queue(maxsize=MAX_OUT_QUEUE)
|
||||
self.writer: asyncio.Task | None = None
|
||||
self.closed = False
|
||||
self._ops_count = 0
|
||||
self._ops_window_start = time.monotonic()
|
||||
self.created_at = time.monotonic()
|
||||
|
||||
def op_budget_ok(self) -> bool:
|
||||
"""Fenêtre glissante simple anti-flood d'opérations."""
|
||||
now = time.monotonic()
|
||||
if now - self._ops_window_start > OP_WINDOW_SECONDS:
|
||||
self._ops_window_start = now
|
||||
self._ops_count = 0
|
||||
self._ops_count += 1
|
||||
return self._ops_count <= OP_WINDOW_MAX
|
||||
|
||||
|
||||
class Room:
|
||||
__slots__ = ("page_id", "blocks", "title", "version", "conns",
|
||||
"persist_task", "dirty")
|
||||
"persist_task", "dirty", "merge_count", "conflict_count")
|
||||
|
||||
def __init__(self, page_id: int):
|
||||
self.page_id = page_id
|
||||
@@ -102,21 +164,21 @@ class Room:
|
||||
self.conns: set[RTConn] = set()
|
||||
self.persist_task: asyncio.Task | None = None
|
||||
self.dirty = False
|
||||
|
||||
|
||||
class RTConn:
|
||||
__slots__ = ("ws", "user", "page_id")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
self.merge_count = 0
|
||||
self.conflict_count = 0
|
||||
|
||||
|
||||
class RealtimeManager:
|
||||
def __init__(self):
|
||||
self._rooms: dict[int, Room] = {}
|
||||
# compteurs globaux (observabilité)
|
||||
self.stat_ops = 0
|
||||
self.stat_merges = 0
|
||||
self.stat_conflicts = 0
|
||||
self.stat_slow_disconnects = 0
|
||||
self.stat_connections_total = 0
|
||||
|
||||
# ── rooms ────────────────────────────────────────────────────────────
|
||||
def room(self, page_id: int) -> Room:
|
||||
return self._rooms.setdefault(page_id, Room(page_id))
|
||||
|
||||
@@ -145,47 +207,95 @@ class RealtimeManager:
|
||||
room.title = row["title"] or ""
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
try:
|
||||
room.blocks = ensure_block_ids(json.loads(row["content"]))
|
||||
blocks = ensure_block_ids(json.loads(row["content"]))
|
||||
# v6.5.0: rooms serve server-resolved synced blocks so a
|
||||
# sync_req never returns a stale cached copy.
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
room.blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
room.blocks = []
|
||||
return True
|
||||
|
||||
# ── entrées / sorties ────────────────────────────────────────────────
|
||||
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
|
||||
room = self.room(page_id)
|
||||
# Ne JAMAIS enregistrer la room avant d'avoir validé l'existence de la
|
||||
# page : avant, un 4404 laissait une Room orpheline en mémoire pour
|
||||
# toujours (fuite).
|
||||
existing = self._rooms.get(page_id)
|
||||
room = existing if existing is not None else Room(page_id)
|
||||
if not room.conns and not await self.load_room(room):
|
||||
await ws.close(code=4404)
|
||||
return None
|
||||
return None # room non enregistrée → pas de fuite
|
||||
if existing is None:
|
||||
self._rooms[page_id] = room
|
||||
|
||||
conn = RTConn(ws, user, page_id)
|
||||
self.stat_connections_total += 1
|
||||
conn.writer = asyncio.create_task(self._writer(conn))
|
||||
room.conns.add(conn)
|
||||
|
||||
me = self._peer(user)
|
||||
peers = [self._peer(c.user) for c in room.conns if c is not conn]
|
||||
await ws.send_json({"t": "welcome", "self": me,
|
||||
"peers": peers, "color": me["color"]})
|
||||
await ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
for c in room.conns:
|
||||
if c is not conn:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_join", "peer": me})
|
||||
except Exception:
|
||||
pass
|
||||
await self._broadcast(room, {"t": "peer_join", "peer": me}, exclude=conn)
|
||||
return conn
|
||||
|
||||
async def disconnect(self, conn: RTConn):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
if room is None:
|
||||
self._close_writer(conn)
|
||||
return
|
||||
room.conns.discard(conn)
|
||||
for c in room.conns:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
except Exception:
|
||||
pass
|
||||
self._close_writer(conn)
|
||||
await self._broadcast(room, {"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
if not room.conns:
|
||||
await self.flush(room)
|
||||
self._rooms.pop(conn.page_id, None)
|
||||
|
||||
def _close_writer(self, conn: RTConn):
|
||||
conn.closed = True
|
||||
t = conn.writer
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
conn.writer = None
|
||||
|
||||
async def _writer(self, conn: RTConn):
|
||||
"""Tâche dédiée : consomme la file sortante et écrit sur le socket.
|
||||
|
||||
Sert aussi de filet de coalescence : après chaque message consommé, les
|
||||
mises à jour de curseur (``sel``) déjà empilées sont réduites à la
|
||||
dernière (les curseurs n'ont pas besoin d'être ordonnés entre eux, seule
|
||||
la position la plus récente compte).
|
||||
"""
|
||||
try:
|
||||
while not conn.closed:
|
||||
msg = await conn.out_q.get()
|
||||
if msg is None:
|
||||
return
|
||||
await conn.ws.send_json(msg)
|
||||
# coalescence des curseurs en attente
|
||||
pending_sel = []
|
||||
while not conn.out_q.empty():
|
||||
nxt = conn.out_q.get_nowait()
|
||||
if nxt is None:
|
||||
return
|
||||
if isinstance(nxt, dict) and nxt.get("t") == "sel":
|
||||
pending_sel.append(nxt)
|
||||
else:
|
||||
await conn.ws.send_json(nxt)
|
||||
if pending_sel:
|
||||
await conn.ws.send_json(pending_sel[-1])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # socket mort → on arrête proprement
|
||||
logger.debug("writer stopped: %s", e)
|
||||
conn.closed = True
|
||||
|
||||
# ── persistance ──────────────────────────────────────────────────────
|
||||
async def flush(self, room: Room):
|
||||
"""Write current room state to DB (sync, used on idle + disconnect)."""
|
||||
if room.persist_task and not room.persist_task.done():
|
||||
@@ -218,67 +328,102 @@ class RealtimeManager:
|
||||
|
||||
room.persist_task = asyncio.create_task(_run())
|
||||
|
||||
# ── broadcast non bloquant ───────────────────────────────────────────
|
||||
def _enqueue(self, conn: RTConn, msg: dict) -> bool:
|
||||
"""Pose le message dans la file du client. Retourne False si trop lent."""
|
||||
if conn.closed:
|
||||
return False
|
||||
try:
|
||||
conn.out_q.put_nowait(msg)
|
||||
return True
|
||||
except asyncio.QueueFull:
|
||||
return False
|
||||
|
||||
async def _evict_slow(self, room: Room, conn: RTConn):
|
||||
"""Client dépassé : on le déconnecte pour ne pas figer la room."""
|
||||
self.stat_slow_disconnects += 1
|
||||
logger.info("realtime: evicting slow client (user=%s page=%s)",
|
||||
conn.user.get("login"), conn.page_id)
|
||||
room.conns.discard(conn)
|
||||
self._close_writer(conn)
|
||||
try:
|
||||
await conn.ws.close(code=4413)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
for c in room.conns:
|
||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||
slow: list[RTConn] = []
|
||||
for c in list(room.conns):
|
||||
if c is exclude:
|
||||
continue
|
||||
try:
|
||||
await c.ws.send_json(msg)
|
||||
except Exception:
|
||||
pass
|
||||
if not self._enqueue(c, msg):
|
||||
slow.append(c)
|
||||
for c in slow:
|
||||
await self._evict_slow(room, c)
|
||||
|
||||
async def _send(self, conn: RTConn, msg: dict):
|
||||
if not self._enqueue(conn, msg):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if room is not None:
|
||||
await self._evict_slow(room, conn)
|
||||
|
||||
# ── protocole ────────────────────────────────────────────────────────
|
||||
async def handle(self, conn: RTConn, msg: dict):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
if room is None:
|
||||
return
|
||||
t = msg.get("t")
|
||||
me = (conn.user.get("id") or 0)
|
||||
|
||||
if t == "hello":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "sync_req":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "ping":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "pong"})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "pong"})
|
||||
return
|
||||
|
||||
if t == "op":
|
||||
if not conn.op_budget_ok():
|
||||
# trop d'ops : on ignore silencieusement (le client resync)
|
||||
await self._send(conn, {"t": "ack", "v": room.version,
|
||||
"stale": True})
|
||||
return
|
||||
op = msg.get("op") or {}
|
||||
client_v = msg.get("v", 0)
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
result = self._apply(room, op)
|
||||
room.version += 1
|
||||
self.stat_ops += 1
|
||||
self._schedule_persist(room)
|
||||
stale = client_v < room.version - 1
|
||||
await self._broadcast(room, {"t": "op", "op": op, "from": me,
|
||||
"v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": stale})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# broadcast : on diffuse TOUJOURS le bloc final fusionné (pas la
|
||||
# proposition brute) pour que tous les clients convergent.
|
||||
out_op = dict(op)
|
||||
if result.get("merged") is not None:
|
||||
out_op["block"] = result["merged"]
|
||||
out_op["merged"] = True
|
||||
await self._broadcast(room, {"t": "op", "op": out_op, "from": me,
|
||||
"v": room.version,
|
||||
"conflict": result.get("conflict", False)},
|
||||
exclude=conn)
|
||||
|
||||
ack = {"t": "ack", "v": room.version, "stale": stale}
|
||||
if result.get("merged") is not None:
|
||||
ack["merged"] = result["merged"]
|
||||
ack["conflict"] = result.get("conflict", False)
|
||||
await self._send(conn, ack)
|
||||
|
||||
if stale:
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync",
|
||||
"blocks": room.blocks,
|
||||
"title": room.title,
|
||||
"version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "title":
|
||||
@@ -289,11 +434,7 @@ class RealtimeManager:
|
||||
self._schedule_persist(room)
|
||||
await self._broadcast(room, {"t": "title", "title": room.title,
|
||||
"from": me, "v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": False})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "ack", "v": room.version, "stale": False})
|
||||
return
|
||||
|
||||
if t == "sel":
|
||||
@@ -303,34 +444,87 @@ class RealtimeManager:
|
||||
"offset": msg.get("offset", 0)}, exclude=conn)
|
||||
return
|
||||
|
||||
def _apply(self, room: Room, op: dict) -> dict:
|
||||
"""Applique une opération en mergeant à 3 voix si le client fournit
|
||||
une ``base``. Retourne ``{"merged": bloc|None, "conflict": bool}``.
|
||||
|
||||
* ``update`` avec ``base`` → merge 3-voix (au-delà du LWW).
|
||||
* le reste (insert/delete/move, ou update sans base) → LWW historique.
|
||||
"""
|
||||
if op.get("type") != "update" or "base" not in op:
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
incoming = op.get("block") or {}
|
||||
base = op.get("base")
|
||||
bid = incoming.get("id")
|
||||
if not bid:
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
current = next((b for b in room.blocks if b.get("id") == bid), None)
|
||||
if current is None:
|
||||
# bloc introuvable : LWW historique = pas d'update possible
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
merged, conflicts = merge_block_3way(base, current, incoming)
|
||||
room.merge_count += 1
|
||||
conflict = bool(conflicts)
|
||||
if conflict:
|
||||
room.conflict_count += 1
|
||||
self.stat_conflicts += 1
|
||||
logger.debug("realtime conflict page=%s block=%s fields=%s",
|
||||
room.page_id, bid, conflicts)
|
||||
self.stat_merges += 1
|
||||
room.blocks = [merged if b.get("id") == bid else b for b in room.blocks]
|
||||
return {"merged": merged, "conflict": conflict}
|
||||
|
||||
# ── observabilité ────────────────────────────────────────────────────
|
||||
async def room_state(self, page_id: int) -> dict:
|
||||
room = self._rooms.get(page_id)
|
||||
if not room.conns and not room.blocks:
|
||||
if room is None:
|
||||
# ne pas créer d'objet None : on charge dans une room jetable
|
||||
room = Room(page_id)
|
||||
if not await self.load_room(room):
|
||||
return {"blocks": [], "title": "", "version": 0}
|
||||
elif not room.conns and not room.blocks:
|
||||
await self.load_room(room)
|
||||
return {"blocks": room.blocks, "title": room.title, "version": room.version}
|
||||
|
||||
async def _propagate_synced(self, synced_id: int) -> None:
|
||||
"""Broadcast a synced-block update to all rooms that reference it."""
|
||||
from app.db import get_conn as _get_conn
|
||||
# Find all pages that reference this synced block
|
||||
pages: list[int] = []
|
||||
try:
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
|
||||
(synced_id,),
|
||||
).fetchall()
|
||||
pages = [r["page_id"] for r in rows]
|
||||
except Exception:
|
||||
return
|
||||
def stats(self) -> dict:
|
||||
rooms = len(self._rooms)
|
||||
conns = sum(len(r.conns) for r in self._rooms.values())
|
||||
return {
|
||||
"rooms": rooms,
|
||||
"connections": conns,
|
||||
"connections_total": self.stat_connections_total,
|
||||
"ops": self.stat_ops,
|
||||
"merges": self.stat_merges,
|
||||
"conflicts": self.stat_conflicts,
|
||||
"slow_disconnects": self.stat_slow_disconnects,
|
||||
"pages": [{"page_id": r.page_id, "conns": len(r.conns),
|
||||
"version": r.version, "merges": r.merge_count,
|
||||
"conflicts": r.conflict_count}
|
||||
for r in self._rooms.values()],
|
||||
}
|
||||
|
||||
async def _broadcast_synced_to(self, pages: list[int], synced_id: int) -> None:
|
||||
"""Broadcast a synced-block event to the given pages' open rooms."""
|
||||
for pid in pages:
|
||||
room = self._rooms.get(pid)
|
||||
if room and room.conns:
|
||||
# Re-load the page content from DB to get fresh synced blocks
|
||||
await self.load_room(room)
|
||||
await self._broadcast(room, {"t": "synced_update",
|
||||
"synced_id": synced_id,
|
||||
"version": room.version})
|
||||
"synced_id": synced_id,
|
||||
"version": room.version})
|
||||
|
||||
async def _propagate_synced(self, synced_id: int) -> None:
|
||||
"""Broadcast a synced-block update to all rooms that reference it."""
|
||||
from app.services.synced_blocks import page_ids_for_synced
|
||||
try:
|
||||
pages = page_ids_for_synced(synced_id)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast_synced_to(pages, synced_id)
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""FlowDeck — v6.5.0 database row content pages (synced blocks production).
|
||||
|
||||
A database row (``collection_pages``) has no block content of its own:
|
||||
its Notion-style content lives in a shadow ``pages`` row linked via
|
||||
``pages.collection_row_id``. The full page editor (synced blocks,
|
||||
versions, realtime, permissions) is reused unchanged on that page.
|
||||
|
||||
``ensure_row_page()`` lazily creates (or heals) the shadow page and is
|
||||
the single entry point used by the row-open endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _host_workspace(conn, coll: dict) -> tuple[str, int | None]:
|
||||
"""Workspace (key, id) the shadow page should live in.
|
||||
|
||||
Resolution order: full-page database host page → inline parent page →
|
||||
the collection's own ``workspace_id`` (key left empty).
|
||||
"""
|
||||
host = conn.execute(
|
||||
"SELECT workspace, workspace_id FROM pages "
|
||||
"WHERE collection_id=? AND deleted_at IS NULL "
|
||||
"AND collection_row_id IS NULL LIMIT 1",
|
||||
(coll.get("id"),),
|
||||
).fetchone()
|
||||
if not host and coll.get("parent_page_id"):
|
||||
host = conn.execute(
|
||||
"SELECT workspace, workspace_id FROM pages WHERE id=?",
|
||||
(coll["parent_page_id"],),
|
||||
).fetchone()
|
||||
if host:
|
||||
return host["workspace"] or "", host["workspace_id"]
|
||||
if coll.get("gitea_owner") and coll.get("gitea_repo"):
|
||||
return f"{coll['gitea_owner']}/{coll['gitea_repo']}", coll.get("workspace_id")
|
||||
return "", coll.get("workspace_id")
|
||||
|
||||
|
||||
def ensure_row_page(row_id: int) -> int:
|
||||
"""Return the content page id for a database row, creating it if needed.
|
||||
|
||||
- existing live page → returned as-is;
|
||||
- soft-deleted page → restored (title resynced);
|
||||
- missing → created with the row's title and the collection workspace.
|
||||
|
||||
Raises ``KeyError`` when the row does not exist.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title FROM collection_pages WHERE id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(f"row {row_id} not found")
|
||||
coll_row = conn.execute(
|
||||
"SELECT id, workspace_id, parent_page_id, gitea_owner, gitea_repo "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
coll = dict(coll_row) if coll_row else {"id": row["collection_id"]}
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, deleted_at, title FROM pages WHERE collection_row_id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["deleted_at"]:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL, parent_section='DbRow', "
|
||||
"title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(row["title"] or existing["title"] or "Untitled", existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return existing["id"]
|
||||
|
||||
ws_key, ws_id = _host_workspace(conn, coll)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages
|
||||
(workspace, workspace_id, title, content, content_format,
|
||||
parent_section, collection_id, collection_row_id)
|
||||
VALUES (?, ?, ?, '', 'blocks', 'DbRow', ?, ?)""",
|
||||
(ws_key, ws_id, row["title"] or "Untitled",
|
||||
row["collection_id"], row_id),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
logger.debug("created content page %s for row %s", page_id, row_id)
|
||||
return page_id
|
||||
|
||||
|
||||
def sync_row_title_to_page(conn, row_id: int) -> None:
|
||||
"""Row → page: copy the row's title onto its content page (if any)."""
|
||||
conn.execute(
|
||||
"""UPDATE pages SET title=(
|
||||
SELECT title FROM collection_pages WHERE id=pages.collection_row_id
|
||||
), updated_at=CURRENT_TIMESTAMP
|
||||
WHERE collection_row_id=? AND EXISTS (
|
||||
SELECT 1 FROM collection_pages cp
|
||||
WHERE cp.id=pages.collection_row_id AND cp.title<>pages.title
|
||||
)""",
|
||||
(row_id,),
|
||||
)
|
||||
|
||||
|
||||
def sync_page_title_to_row(conn, page_id: int) -> None:
|
||||
"""Page → row: a rename inside the editor propagates to the row."""
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages SET title=(
|
||||
SELECT title FROM pages WHERE id=?
|
||||
), updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=(SELECT collection_row_id FROM pages WHERE id=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pages p
|
||||
WHERE p.id=? AND p.collection_row_id IS NOT NULL
|
||||
AND p.title<>collection_pages.title
|
||||
)""",
|
||||
(page_id, page_id, page_id),
|
||||
)
|
||||
@@ -61,11 +61,17 @@ def update_synced_block(synced_id: int, title: str,
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_synced_block(synced_id: int) -> None:
|
||||
"""Delete a synced block (cascades via FK)."""
|
||||
def delete_synced_block(synced_id: int) -> list[int]:
|
||||
"""Delete a synced block (cascades via FK).
|
||||
|
||||
v6.5.0: returns the page ids that referenced it so the caller can
|
||||
refresh/broadcast them (the FK cascade removes ``page_synced_blocks``).
|
||||
"""
|
||||
page_ids = page_ids_for_synced(synced_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
|
||||
conn.commit()
|
||||
return page_ids
|
||||
|
||||
|
||||
def list_synced_blocks(workspace: str) -> list[dict]:
|
||||
@@ -136,18 +142,55 @@ def resolve_synced_block(blocks: list[dict]) -> list[dict]:
|
||||
b["_synced_title"] = sb.get("title", "")
|
||||
b["_synced_id"] = sb["id"]
|
||||
b["_synced_updated"] = sb.get("updated_at", "")
|
||||
b.pop("_synced_deleted", None)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif not b.get("_synced_deleted"):
|
||||
# v6.5.0: source deleted → instances render a clear state
|
||||
# instead of a stale cache / eternal "Loading…".
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
if isinstance(b.get("children"), list):
|
||||
resolve_synced_block(b["children"])
|
||||
return blocks
|
||||
|
||||
|
||||
def page_ids_for_synced(synced_id: int) -> list[int]:
|
||||
"""Pages currently referencing a synced block (before any cascade)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
|
||||
(synced_id,),
|
||||
).fetchall()
|
||||
return [r["page_id"] for r in rows]
|
||||
|
||||
|
||||
def resolve_content_json(content: str, content_format: str | None) -> str:
|
||||
"""Resolve synced-block references inside a page's stored blocks JSON.
|
||||
|
||||
Server-side freshness guarantee (v6.5.0): every read path passes through
|
||||
this so a stale cached ``_synced_content`` is replaced by the current
|
||||
source-of-truth content. Non-blocks content is returned unchanged.
|
||||
"""
|
||||
if (content_format or "blocks") != "blocks" or not content:
|
||||
return content
|
||||
try:
|
||||
blocks = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return content
|
||||
if not isinstance(blocks, list):
|
||||
return content
|
||||
return json.dumps(resolve_synced_block(blocks), ensure_ascii=False)
|
||||
|
||||
|
||||
def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
"""Re-resolve all synced blocks in a page's content.
|
||||
|
||||
Called after a synced block is updated — refreshes the referencing
|
||||
pages' block content so they reflect the latest source.
|
||||
pages' stored content so they reflect the latest source (v6.5.0: this
|
||||
used to be dead code; it is now invoked by the update/delete routes,
|
||||
recursively covering blocks nested in columns/toggles).
|
||||
"""
|
||||
refs = get_page_synced(page_id)
|
||||
if not refs:
|
||||
@@ -163,22 +206,48 @@ def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
|
||||
sources: dict[int, dict] = {r["synced_block_id"]: get_synced_block(r["synced_block_id"])
|
||||
for r in refs}
|
||||
changed = False
|
||||
for ref in refs:
|
||||
sb = get_synced_block(ref["synced_block_id"])
|
||||
if sb:
|
||||
try:
|
||||
resolved = json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id") == ref["synced_block_id"]:
|
||||
b["content"] = json.dumps(resolved, ensure_ascii=False) if isinstance(resolved, list) else json.dumps(resolved)
|
||||
b["_synced_content"] = resolved
|
||||
b["_synced_title"] = sb.get("title", "")
|
||||
|
||||
def _walk(bl: list[dict]) -> None:
|
||||
nonlocal changed
|
||||
for b in bl:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
sid = b["synced_id"]
|
||||
if sid not in sources:
|
||||
sources[sid] = get_synced_block(sid)
|
||||
sb = sources.get(sid)
|
||||
if sb:
|
||||
try:
|
||||
resolved = json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
content_str = (json.dumps(resolved, ensure_ascii=False)
|
||||
if isinstance(resolved, list)
|
||||
else json.dumps(resolved))
|
||||
title = sb.get("title", "")
|
||||
if (b.get("content") != content_str
|
||||
or b.get("_synced_content") != resolved
|
||||
or b.get("_synced_title") != title
|
||||
or b.get("_synced_deleted")):
|
||||
b["content"] = content_str
|
||||
b["_synced_content"] = resolved
|
||||
b["_synced_title"] = title
|
||||
b.pop("_synced_deleted", None)
|
||||
changed = True
|
||||
elif not b.get("_synced_deleted"):
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
changed = True
|
||||
break
|
||||
if isinstance(b.get("children"), list):
|
||||
_walk(b["children"])
|
||||
|
||||
_walk(blocks)
|
||||
|
||||
if changed:
|
||||
with get_conn() as conn:
|
||||
@@ -189,6 +258,52 @@ def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
|
||||
"""Flag a deleted synced block's instances as ``_synced_deleted``.
|
||||
|
||||
v6.5.0 — called after ``delete_synced_block()``: the FK cascade already
|
||||
removed ``page_synced_blocks`` refs, so ``sync_synced_blocks_in_page``
|
||||
cannot be used here; this rewrites the stored blocks directly so every
|
||||
surface (editor, offline sync, export) sees the deleted state.
|
||||
"""
|
||||
for pid in page_ids:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row or row["content_format"] != "blocks":
|
||||
continue
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(blocks, list):
|
||||
continue
|
||||
changed = False
|
||||
|
||||
def _walk(bl: list[dict]) -> None:
|
||||
nonlocal changed
|
||||
for b in bl:
|
||||
if (b.get("type") == "synced"
|
||||
and b.get("synced_id") == synced_id
|
||||
and not b.get("_synced_deleted")):
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
changed = True
|
||||
if isinstance(b.get("children"), list):
|
||||
_walk(b["children"])
|
||||
|
||||
_walk(blocks)
|
||||
if changed:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(blocks, ensure_ascii=False), pid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Unsync: convert synced block to independent copy ──────────────
|
||||
|
||||
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"""FlowDeck — Web Clipper service (v6.0.0).
|
||||
|
||||
Handles web content capture → FlowDeck page creation.
|
||||
|
||||
- Sanitizes incoming HTML (removes scripts, styles, event handlers).
|
||||
- Extracts readable content via BeautifulSoup heuristics (article/main/body).
|
||||
- Converts HTML → FlowDeck block list (headings, paragraphs, lists, quotes,
|
||||
code, images, bookmarks).
|
||||
- Creates a pages row (workspace-aware) and logs the clip.
|
||||
|
||||
No external network calls: images stay as remote URLs (no download in MVP);
|
||||
a future iteration can download + re-host inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CLIP_BYTES = 10 * 1024 * 1024 # 10 MB per clip
|
||||
MAX_CLIPS_PER_HOUR = 50
|
||||
|
||||
# In-memory rate limiter per device: {device_id: [timestamps]}
|
||||
_rate_store: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _check_rate_limit(device_id: str) -> bool:
|
||||
"""Return True if allowed, False if rate-limited (50/hour)."""
|
||||
now = time.time()
|
||||
window = 3600
|
||||
bucket = _rate_store.get(device_id, [])
|
||||
bucket = [t for t in bucket if now - t < window]
|
||||
if len(bucket) >= MAX_CLIPS_PER_HOUR:
|
||||
_rate_store[device_id] = bucket
|
||||
return False
|
||||
bucket.append(now)
|
||||
_rate_store[device_id] = bucket
|
||||
return True
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def sanitize_html(html_str: str) -> str:
|
||||
"""Strip dangerous tags/attributes, return sanitized HTML string."""
|
||||
if not html_str:
|
||||
return ""
|
||||
# Cap size
|
||||
if len(html_str.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
html_str = html_str[: MAX_CLIP_BYTES // 2]
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "iframe"]):
|
||||
tag.decompose()
|
||||
# Strip event handlers and javascript: URLs
|
||||
for el in soup.find_all(True):
|
||||
for attr in list(el.attrs):
|
||||
if attr.lower().startswith("on"):
|
||||
del el.attrs[attr]
|
||||
elif attr in ("href", "src", "action"):
|
||||
val = str(el.attrs[attr]).strip()
|
||||
if val.lower().startswith("javascript:") or val.lower().startswith("data:text/html"):
|
||||
del el.attrs[attr]
|
||||
return str(soup)
|
||||
|
||||
|
||||
def _extract_main(soup: BeautifulSoup) -> BeautifulSoup:
|
||||
"""Pick the most content-rich container: article > main > body."""
|
||||
for sel in ["article", "main", "[role=article]", "#content", ".post-content", ".article-content"]:
|
||||
el = soup.select_one(sel)
|
||||
if el and len(el.get_text(strip=True)) > 120:
|
||||
return el
|
||||
return soup.body or soup
|
||||
|
||||
|
||||
def _text_node(el) -> str:
|
||||
return el.get_text(separator=" ", strip=True) if el else ""
|
||||
|
||||
|
||||
def html_to_blocks(html_str: str, source_url: str = "") -> list[dict]:
|
||||
"""Convert HTML → FlowDeck block list.
|
||||
|
||||
Covers: headings (h1-h4), paragraphs, blockquotes, code, lists,
|
||||
images, links as bookmark when standalone.
|
||||
"""
|
||||
if not html_str or not html_str.strip():
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
main = _extract_main(soup)
|
||||
blocks: list[dict] = []
|
||||
|
||||
def _add(b):
|
||||
if b.get("content") or b.get("src") or b.get("url"):
|
||||
blocks.append(b)
|
||||
|
||||
# Walk direct children and deeper elements
|
||||
for el in main.find_all(["h1", "h2", "h3", "h4", "p", "blockquote", "pre", "ul", "ol", "img", "figure", "a"], recursive=True):
|
||||
tag = el.name.lower()
|
||||
if tag in ("h1", "h2", "h3", "h4"):
|
||||
level = int(tag[1])
|
||||
level = min(level, 4)
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": f"heading_{level}", "content": txt})
|
||||
elif tag == "p":
|
||||
txt = _text_node(el)
|
||||
# Skip if parent is blockquote or li already handled
|
||||
if el.find_parent(["blockquote", "li"]):
|
||||
continue
|
||||
# If p contains an image, emit image even when text empty
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
# If there is also text alongside image, emit paragraph too
|
||||
if txt:
|
||||
# Remove image alt from paragraph? Keep text
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
continue
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
elif tag == "blockquote":
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "quote", "content": txt})
|
||||
elif tag == "pre":
|
||||
code_el = el.find("code")
|
||||
txt = (code_el.get_text() if code_el else el.get_text())
|
||||
if txt.strip():
|
||||
lang = ""
|
||||
if code_el and code_el.get("class"):
|
||||
for c in code_el.get("class"):
|
||||
if c.startswith("language-"):
|
||||
lang = c.replace("language-", "")
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "code", "content": txt.strip("\n"), "language": lang})
|
||||
elif tag in ("ul", "ol"):
|
||||
# Only top-level lists - skip nested
|
||||
if el.find_parent(["ul", "ol"]):
|
||||
continue
|
||||
is_ordered = tag == "ol"
|
||||
for li in el.find_all("li", recursive=False):
|
||||
txt = _text_node(li)
|
||||
if not txt:
|
||||
continue
|
||||
# Detect todo
|
||||
if re.match(r"^\[ ?[xX] ?\]\s*", txt):
|
||||
checked = bool(re.match(r"^\[ ?[xX] ?\]", txt))
|
||||
txt = re.sub(r"^\[ ?[xX] ?\]\s*", "", txt)
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "to_do", "content": txt, "checked": checked})
|
||||
else:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bulleted_list" if not is_ordered else "numbered_list", "content": txt})
|
||||
elif tag == "img":
|
||||
# Avoid double-count when inside p/figure already emitted
|
||||
if el.find_parent("p") or el.find_parent("figure"):
|
||||
# Still emit if parent p wasn't counted
|
||||
parent_p = el.find_parent("p")
|
||||
if parent_p and parent_p.find("img") == el:
|
||||
continue
|
||||
src = el.get("src", "").strip()
|
||||
if not src:
|
||||
continue
|
||||
# Skip data URIs for size
|
||||
if src.startswith("data:"):
|
||||
continue
|
||||
alt = el.get("alt", "") or ""
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
elif tag == "a":
|
||||
# Standalone links as bookmark when they are the only content in p
|
||||
parent = el.find_parent("p")
|
||||
txt = el.get_text(strip=True)
|
||||
href = el.get("href", "").strip()
|
||||
if href and parent and _text_node(parent) == txt and href.startswith("http"):
|
||||
# Will be handled as paragraph already; add bookmark variant if distinct
|
||||
pass
|
||||
elif tag == "figure":
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
|
||||
if not blocks:
|
||||
# Fallback: whole text as paragraphs
|
||||
texts = [t.strip() for t in main.get_text(separator="\n").split("\n") if t.strip()]
|
||||
for t in texts[:30]:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": t})
|
||||
|
||||
# Always ensure at least one block when source_url present
|
||||
if not blocks and source_url:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": source_url, "title": source_url})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_article(html_str: str, url: str = "") -> dict:
|
||||
"""High-level extraction returning title, text, images, metadata."""
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
title = ""
|
||||
if soup.title and soup.title.string:
|
||||
title = soup.title.string.strip()
|
||||
og_title = soup.find("meta", property="og:title")
|
||||
if og_title and og_title.get("content"):
|
||||
title = og_title["content"].strip() or title
|
||||
# Sanitize and convert
|
||||
clean = sanitize_html(html_str)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
# Images
|
||||
images = []
|
||||
for b in blocks:
|
||||
if b.get("type") == "image" and b.get("src"):
|
||||
images.append(b["src"])
|
||||
# Text
|
||||
text_parts = []
|
||||
for b in blocks:
|
||||
if b.get("content"):
|
||||
text_parts.append(b["content"])
|
||||
return {"title": title or "Clipped page", "blocks": blocks, "images": images, "text": "\n\n".join(text_parts)}
|
||||
|
||||
|
||||
def _ensure_workspace(conn, user_id: int, workspace_id: int | None) -> tuple[int, str]:
|
||||
"""Return (workspace_id, workspace_key) for clip insertion."""
|
||||
if workspace_id:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if row:
|
||||
# Check membership or owner
|
||||
mem = conn.execute(
|
||||
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user_id)
|
||||
).fetchone()
|
||||
if mem or conn.execute("SELECT 1 FROM workspaces WHERE id=? AND owner_id=?", (workspace_id, user_id)).fetchone():
|
||||
return workspace_id, row["name"]
|
||||
# Fallback: first workspace owned or member, else create one
|
||||
row = conn.execute(
|
||||
"SELECT w.id, w.name FROM workspaces w LEFT JOIN workspace_members wm ON w.id=wm.workspace_id "
|
||||
"WHERE w.owner_id=? OR wm.user_id=? ORDER BY w.id LIMIT 1",
|
||||
(user_id, user_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
# Create default workspace
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("My Workspace", user_id))
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'admin')", (ws_id, user_id))
|
||||
return ws_id, "My Workspace"
|
||||
|
||||
|
||||
def create_page_from_clip(clip_data: dict, user_id: int) -> dict:
|
||||
"""Create a FlowDeck page from a clip payload. Returns {page_id, title}."""
|
||||
url = (clip_data.get("url") or clip_data.get("source_url") or "").strip()
|
||||
title = (clip_data.get("title") or "").strip()
|
||||
content = clip_data.get("content") or clip_data.get("html") or ""
|
||||
content_type = clip_data.get("content_type") or clip_data.get("clip_type") or "article"
|
||||
selection_html = clip_data.get("selection_html") or ""
|
||||
tags = clip_data.get("tags") or []
|
||||
target_ws = clip_data.get("target_workspace_id") or clip_data.get("workspace_id")
|
||||
target_page_id = clip_data.get("target_page_id") or clip_data.get("parent_page_id")
|
||||
# Normalize workspace id
|
||||
try:
|
||||
target_ws = int(target_ws) if target_ws is not None else None
|
||||
except (ValueError, TypeError):
|
||||
target_ws = None
|
||||
|
||||
# Determine title and blocks
|
||||
if content_type == "screenshot":
|
||||
# Screenshot: base64 image block + source bookmark (even with empty html)
|
||||
img_b64 = clip_data.get("image_base64") or clip_data.get("screenshot") or ""
|
||||
blocks = []
|
||||
if img_b64:
|
||||
src = img_b64 if img_b64.startswith("data:") else f"data:image/png;base64,{img_b64}"
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": title or "Screenshot"})
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
if not blocks:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": title or "Screenshot"})
|
||||
title = title or "Screenshot"
|
||||
elif content_type == "selection" and selection_html and selection_html.strip():
|
||||
clean = sanitize_html(selection_html)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
title = title or "Clipped selection"
|
||||
elif content_type == "bookmark" or not content.strip():
|
||||
# Bookmark mode: no HTML body, just link card
|
||||
bookmark_title = title or (url or "Bookmark")
|
||||
blocks = [
|
||||
{"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": bookmark_title, "description": clip_data.get("metadata", {}).get("og_description", "") or ""}
|
||||
]
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": f"Source: {url}"})
|
||||
title = bookmark_title
|
||||
else:
|
||||
# Article full
|
||||
result = extract_article(content, url=url)
|
||||
blocks = result["blocks"]
|
||||
if not title:
|
||||
title = result["title"]
|
||||
# Append source bookmark if not already dominant
|
||||
if url:
|
||||
# avoid duplicate bookmark if last block already is bookmark to same url
|
||||
if not blocks or blocks[-1].get("url") != url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": "Source"})
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": url})
|
||||
|
||||
# Cap blocks
|
||||
if len(blocks) > 200:
|
||||
blocks = blocks[:200]
|
||||
title = (title or "Clipped page").strip()[:200] or "Clipped page"
|
||||
|
||||
# Persist
|
||||
with get_conn() as conn:
|
||||
ws_id, ws_key = _ensure_workspace(conn, user_id, target_ws)
|
||||
# Validate target_page_id belongs to same workspace
|
||||
parent_id = None
|
||||
if target_page_id:
|
||||
try:
|
||||
pid = int(target_page_id)
|
||||
pr = conn.execute("SELECT id, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
||||
if pr and (pr["workspace_id"] == ws_id or pr["workspace_id"] is None):
|
||||
parent_id = pid
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Determine sort order
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE parent_id=?", (parent_id,)).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE workspace_id=? AND parent_id IS NULL", (ws_id,)).fetchone()[0]
|
||||
blocks_json = json.dumps(blocks, ensure_ascii=False)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?, ?, ?, ?, 'blocks', 'Private', ?, ?)""",
|
||||
(ws_key or "", ws_id, title, blocks_json, parent_id, next_order),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
# Tags: create per-user tags if needed and attach via page_tags
|
||||
for tname in tags[:10]:
|
||||
tn = tname.strip()[:50]
|
||||
if not tn:
|
||||
continue
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name, color, user_id) VALUES (?, '#787774', ?)", (tn, user_id))
|
||||
tr = conn.execute("SELECT id FROM tags WHERE name=? AND user_id=?", (tn, user_id)).fetchone()
|
||||
if tr:
|
||||
conn.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tr["id"]))
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "title": title, "blocks": blocks, "workspace_id": ws_id}
|
||||
|
||||
|
||||
def register_device(user_id: int, device_id: str, device_name: str = "", extension_name: str = "chrome") -> dict:
|
||||
"""Register or update an extension device, returns {device, token} (token shown once if new)."""
|
||||
if not device_id or len(device_id) > 128:
|
||||
raise ValueError("Invalid device_id")
|
||||
token = f"fd_clip_{uuid.uuid4().hex}{uuid.uuid4().hex[:8]}"
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_hash FROM extension_devices WHERE user_id=? AND device_id=? AND extension_name=?",
|
||||
(user_id, device_id, extension_name),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE extension_devices SET device_name=?, last_used_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(device_name[:200], existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": existing["id"], "device_id": device_id, "token": None, "existing": True}
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO extension_devices (user_id, extension_name, device_id, device_name, token_hash)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(user_id, extension_name, device_id, device_name[:200], thash),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
|
||||
|
||||
|
||||
def verify_device_token(device_id: str, token: str) -> dict | None:
|
||||
"""Verify a device token, returns device row or None."""
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
|
||||
(device_id, thash),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO extension_clips (user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, device_id, clip_type[:20], source_url[:2000], target_page_id, workspace_id, title[:200]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_devices(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, extension_name, device_id, device_name, scopes, last_used_at, created_at, revoked FROM extension_devices WHERE user_id=? ORDER BY last_used_at DESC, created_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
# Enrich with clip counts
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
cnt = conn.execute("SELECT COUNT(*) AS n FROM extension_clips WHERE user_id=? AND device_id=?", (user_id, r["device_id"])).fetchone()["n"]
|
||||
d["clips_count"] = cnt
|
||||
# Last clip
|
||||
last = conn.execute("SELECT created_at FROM extension_clips WHERE user_id=? AND device_id=? ORDER BY created_at DESC LIMIT 1", (user_id, r["device_id"])).fetchone()
|
||||
d["last_clip_at"] = last["created_at"] if last else None
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def revoke_device(user_id: int, device_row_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE extension_devices SET revoked=1 WHERE id=? AND user_id=?", (device_row_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -1,7 +1,16 @@
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0, prod v6.4.0).
|
||||
|
||||
Production-grade delivery: HMAC-SHA256 signature (``X-FlowDeck-Signature``),
|
||||
retry with backoff 2s/10s/60s, full delivery journal in ``webhook_deliveries``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -9,29 +18,254 @@ from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Event catalogue (≈50 events) ────────────────────────────────────────────
|
||||
EVENTS = [
|
||||
"page.created", "page.updated", "page.deleted",
|
||||
# pages (block-editor documents)
|
||||
"page.created", "page.updated", "page.deleted", "page.moved",
|
||||
"page.restored", "page.locked", "page.unlocked",
|
||||
"page.shared", "page.published", "page.unpublished",
|
||||
"page.renamed", "page.duplicated", "page.archived",
|
||||
# comments & collaboration
|
||||
"comment.added", "comment.updated", "comment.resolved", "comment.deleted",
|
||||
"mention.added", "mention.resolved",
|
||||
# collections (databases)
|
||||
"collection.created", "collection.updated", "collection.deleted",
|
||||
"comment.added", "page.moved",
|
||||
"collection.renamed", "collection.duplicated",
|
||||
"collection.page.created", "collection.page.updated", "collection.page.deleted",
|
||||
"collection.page.moved", "collection.view.created", "collection.view.updated",
|
||||
"collection.view.deleted", "collection.property.created", "collection.property.updated",
|
||||
"collection.property.deleted",
|
||||
# sprints / tasks
|
||||
"sprint.created", "sprint.updated", "sprint.deleted",
|
||||
"sprint.started", "sprint.completed", "sprint.canceled",
|
||||
# sharing / favorites
|
||||
"favorite.added", "favorite.removed",
|
||||
"share.created", "share.updated", "share.revoked",
|
||||
# automations & agent
|
||||
"automation.fired", "automation.failed", "automation.retrying",
|
||||
"agent.run.started", "agent.run.finished", "agent.run.failed",
|
||||
# workspace & users
|
||||
"workspace.created", "workspace.updated", "workspace.deleted",
|
||||
"workspace.member.added", "workspace.member.removed", "workspace.member.role_changed",
|
||||
# files & imports
|
||||
"file.uploaded", "file.deleted",
|
||||
"import.started", "import.completed", "import.failed",
|
||||
# generic
|
||||
"ping",
|
||||
]
|
||||
|
||||
# Retry schedule: delays in seconds between attempts (attempt 0 = immediate).
|
||||
RETRY_DELAYS = (2, 10, 60)
|
||||
MAX_ATTEMPTS = len(RETRY_DELAYS) + 1 # 1 initial + 3 retries
|
||||
|
||||
|
||||
def sign_payload(secret: str, body: bytes) -> str:
|
||||
"""HMAC-SHA256 hex signature of the raw JSON body (``sha256=<hex>``)."""
|
||||
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_signature(secret: str, body: bytes, signature: str) -> bool:
|
||||
"""Constant-time check of an ``X-FlowDeck-Signature`` header value."""
|
||||
if not secret or not signature:
|
||||
return False
|
||||
expected = sign_payload(secret, body)
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _event_matches(sub_event: str, event: str) -> bool:
|
||||
"""Wildcard matching: ``*`` matches all, ``page.*`` matches page.* events."""
|
||||
if sub_event in ("*", "all"):
|
||||
return True
|
||||
if sub_event.endswith(".*"):
|
||||
return event.startswith(sub_event[:-1])
|
||||
return sub_event == event
|
||||
|
||||
|
||||
def _matching_subs(conn, event: str) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT id, url, event, secret FROM webhook_subscriptions WHERE active=1"
|
||||
).fetchall()
|
||||
return [r for r in rows if _event_matches((r["event"] or "").strip(), event)]
|
||||
|
||||
|
||||
def _log_delivery(webhook_id: int, event: str, status: str, http_code: int | None,
|
||||
error: str, duration_ms: int, attempt: int, payload: dict,
|
||||
next_retry_at: float | None = None) -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO webhook_deliveries
|
||||
(webhook_id, event, status, http_code, error, duration_ms,
|
||||
attempt, payload, next_retry_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(webhook_id, event, status,
|
||||
http_code if http_code is not None else 0,
|
||||
(error or "")[:1000], duration_ms, attempt,
|
||||
json.dumps(payload)[:8000],
|
||||
next_retry_at),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 — logging must never break delivery
|
||||
logger.debug("Failed to log webhook delivery", exc_info=True)
|
||||
|
||||
|
||||
async def _deliver_once(client: httpx.AsyncClient, url: str, event: str,
|
||||
payload: dict, secret: str) -> tuple[int | None, str]:
|
||||
"""Single POST attempt. Returns (http_code, error).
|
||||
|
||||
Includes HMAC-SHA256 signature in X-FlowDeck-Signature header.
|
||||
"""
|
||||
body = json.dumps({"event": event, **payload}).encode()
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-FlowDeck-Event": event,
|
||||
"User-Agent": "FlowDeck-Webhooks/1.0",
|
||||
}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Signature"] = sign_payload(secret, body)
|
||||
# legacy header kept for backward compatibility
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
try:
|
||||
resp = await client.post(url, content=body, headers=headers, timeout=30.0)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return resp.status_code, ""
|
||||
return resp.status_code, f"HTTP {resp.status_code}"
|
||||
except httpx.TimeoutException as exc:
|
||||
return None, f"Timeout: {str(exc)[:400]}"
|
||||
except httpx.RequestError as exc:
|
||||
return None, f"Request error: {str(exc)[:400]}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, str(exc)[:500]
|
||||
|
||||
|
||||
async def deliver_to_sub(sub_id: int, url: str, event: str, payload: dict,
|
||||
secret: str, *, _client: httpx.AsyncClient | None = None) -> bool:
|
||||
"""Deliver with retry (immediate + 2s/10s/60s). Logs every attempt.
|
||||
|
||||
Returns True on success. Failures are re-queued via ``next_retry_at`` so
|
||||
the background scheduler can pick them up even if this process restarts.
|
||||
|
||||
Retry schedule: 2s, 10s, 60s (3 retries total + initial attempt).
|
||||
"""
|
||||
own_client = _client is None
|
||||
client = _client or httpx.AsyncClient(timeout=30.0)
|
||||
try:
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
if attempt > 0:
|
||||
delay = RETRY_DELAYS[attempt - 1]
|
||||
logger.debug("Webhook retry attempt %d/%d for %s after %ds", attempt, MAX_ATTEMPTS, url, delay)
|
||||
await asyncio.sleep(delay)
|
||||
start = time.monotonic()
|
||||
code, err = await _deliver_once(client, url, event, payload, secret or "")
|
||||
duration = int((time.monotonic() - start) * 1000)
|
||||
ok = code is not None and 200 <= code < 300
|
||||
last = attempt == MAX_ATTEMPTS - 1
|
||||
if ok or last:
|
||||
_log_delivery(sub_id, event, "delivered" if ok else "failed",
|
||||
code, err, duration, attempt, payload)
|
||||
return ok
|
||||
# schedule retry
|
||||
delay = RETRY_DELAYS[attempt]
|
||||
_log_delivery(sub_id, event, "retrying", code, err, duration,
|
||||
attempt, payload,
|
||||
next_retry_at=time.time() + delay)
|
||||
return False
|
||||
finally:
|
||||
if own_client:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Fire a webhook event to all registered subscribers."""
|
||||
"""Fire a webhook event to all registered subscribers (wildcard-aware).
|
||||
|
||||
Unknown events are ignored. Delivery itself never raises: each
|
||||
subscription is attempted independently and journaled.
|
||||
"""
|
||||
if event not in EVENTS:
|
||||
logger.debug("Ignoring unknown webhook event: %s", event)
|
||||
return
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute("SELECT url, secret FROM webhook_subscriptions WHERE event=?", (event,)).fetchall()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
subs = _matching_subs(conn, event)
|
||||
if not subs:
|
||||
logger.debug("No subscribers for event: %s", event)
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
for sub in subs:
|
||||
url, secret = sub["url"], sub["secret"]
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": event}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
try:
|
||||
await client.post(url, json=payload, headers=headers)
|
||||
await deliver_to_sub(sub["id"], sub["url"], event,
|
||||
dict(payload), sub["secret"] or "",
|
||||
_client=client)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Webhook delivery failed to %s: %s", sub["url"], exc)
|
||||
|
||||
|
||||
async def retry_due_deliveries(now: float | None = None) -> int:
|
||||
"""Re-fire deliveries stuck in ``retrying`` whose ``next_retry_at`` passed.
|
||||
|
||||
Returns the number of deliveries retried. Called by the background
|
||||
scheduler and directly testable.
|
||||
"""
|
||||
now = now if now is not None else time.time()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT d.id, d.webhook_id, d.event, d.payload, d.attempt,
|
||||
s.url, s.secret
|
||||
FROM webhook_deliveries d
|
||||
JOIN webhook_subscriptions s ON s.id = d.webhook_id
|
||||
WHERE d.status='retrying' AND s.active=1
|
||||
AND d.next_retry_at IS NOT NULL AND d.next_retry_at <= ?
|
||||
ORDER BY d.next_retry_at LIMIT 50""",
|
||||
(now,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for r in rows:
|
||||
try:
|
||||
payload = json.loads(r["payload"] or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {"data": payload}
|
||||
except Exception:
|
||||
logger.debug("Webhook delivery failed to %s", url)
|
||||
payload = {}
|
||||
event = r["event"] or "ping"
|
||||
# resume where the previous run stopped, up to MAX_ATTEMPTS
|
||||
start_attempt = min((r["attempt"] or 0) + 1, MAX_ATTEMPTS - 1)
|
||||
for att in range(start_attempt, MAX_ATTEMPTS):
|
||||
delay = RETRY_DELAYS[att - 1] if att > 0 else 0
|
||||
if delay:
|
||||
await asyncio.sleep(delay)
|
||||
t0 = time.monotonic()
|
||||
code, err = await _deliver_once(client, r["url"], event,
|
||||
payload, r["secret"] or "")
|
||||
duration = int((time.monotonic() - t0) * 1000)
|
||||
ok = code is not None and 200 <= code < 300
|
||||
last = att == MAX_ATTEMPTS - 1
|
||||
if ok or last:
|
||||
_log_delivery(r["webhook_id"], event,
|
||||
"delivered" if ok else "failed",
|
||||
code, err, duration, att, payload)
|
||||
break
|
||||
_log_delivery(r["webhook_id"], event, "retrying", code, err,
|
||||
duration, att, payload,
|
||||
next_retry_at=time.time() + RETRY_DELAYS[att])
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE webhook_deliveries SET status='superseded' WHERE id=?", (r["id"],))
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def webhook_retry_scheduler(interval_seconds: int = 60) -> None:
|
||||
"""Background loop: retry due webhook deliveries every minute."""
|
||||
from app.config import settings as _settings
|
||||
interval = getattr(_settings, "webhook_retry_interval_seconds", interval_seconds)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await retry_due_deliveries()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("webhook_retry_scheduler tick failed", exc_info=True)
|
||||
|
||||
|
||||
def init_webhook_tables():
|
||||
|
||||
@@ -1412,7 +1412,21 @@
|
||||
};
|
||||
}
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; }
|
||||
// v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId}
|
||||
// opened an unrelated page with the same id) — resolve the row's
|
||||
// content page first (lazy-created), then load the real editor.
|
||||
resolveRowContentPage(pageId).then(function(pid){
|
||||
var fr = document.getElementById('db-side-peek-iframe');
|
||||
if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; }
|
||||
});
|
||||
if (iframe) panel.style.display = 'flex';
|
||||
}
|
||||
|
||||
function resolveRowContentPage(rowId){
|
||||
return fetch('/db/pages/'+rowId+'/open/api')
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(d){ return (d && d.page_id) ? d.page_id : rowId; })
|
||||
.catch(function(){ return rowId; });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -94,7 +94,27 @@
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
|
||||
<template x-if="inviteGroups && inviteGroups.length">
|
||||
<div>
|
||||
<div class="sd-user-empty" style="font-weight:600;">Groups</div>
|
||||
<template x-for="g in inviteGroups" :key="'g'+g.id">
|
||||
<div
|
||||
class="sd-user-item"
|
||||
@click="pickInviteGroup(g)"
|
||||
>
|
||||
<div class="sd-user-avatar" style="background:#5b5bd6;color:#fff;">
|
||||
<span x-text="(g.name || '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div class="sd-user-meta">
|
||||
<div class="sd-user-login" x-text="g.name"></div>
|
||||
<div class="sd-user-name" x-text="'Group · ' + (g.member_count || 0) + ' members'"></div>
|
||||
</div>
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length && !(inviteGroups && inviteGroups.length)">No matching users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,17 +133,22 @@
|
||||
<div class="sd-avatar" x-show="a.user_avatar_url" style="overflow:hidden">
|
||||
<img :src="a.user_avatar_url" style="width:100%;height:100%;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
<div class="sd-avatar" x-show="!a.user_avatar_url"
|
||||
<!-- Group avatar -->
|
||||
<div class="sd-avatar" x-show="(a.kind === 'group' || a.shared_with_group_id) && !a.user_avatar_url"
|
||||
style="background:#5b5bd6;color:#fff;"
|
||||
x-text="(a.group_name || '?')[0].toUpperCase()"
|
||||
></div>
|
||||
<div class="sd-avatar" x-show="!(a.kind === 'group' || a.shared_with_group_id) && !a.user_avatar_url"
|
||||
x-text="(a.user_full_name || a.user_login || a.shared_with_email || '?')[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.user_full_name || a.user_login || a.shared_with_email"
|
||||
x-text="(a.kind === 'group' || a.shared_with_group_id) ? (a.group_name || 'Group') : (a.user_full_name || a.user_login || a.shared_with_email)"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.shared_with_email || a.user_login || ''"
|
||||
x-text="(a.kind === 'group' || a.shared_with_group_id) ? 'Group' : (a.shared_with_email || a.user_login || '')"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,9 @@ window.__fdRT = (function () {
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
ops.push({ type: 'update', block: clone(b) });
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
@@ -358,6 +360,27 @@ window.__fdRT = (function () {
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
|
||||
@@ -548,10 +548,11 @@ window.__fdEditorScriptsLoaded = true;
|
||||
}
|
||||
if(b.type==='bookmark'){
|
||||
var url=b.url||b.src||'';var title=esc(b.title||url);var desc=esc(b.description||'');var img=b.image||'';var site=esc(b.site_name||'');
|
||||
var imgHtml=img?'<img src="'+img+'" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">':'';
|
||||
var imgHtml=img?'<img src="'+img.replace(/"/g,'"')+'" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">':'';
|
||||
var descHtml=desc?'<div style="font-size:13px;color:var(--text-dim);margin-top:4px;">'+desc+'</div>':'';
|
||||
var siteHtml=site?'<div style="font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">'+site+'</div>':'';
|
||||
return url?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark"><a href="'+url+'" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;"><div style="display:flex;gap:14px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:0;background:var(--bg-secondary);"><div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">'+title+'</div>'+descHtml+siteHtml+'</div>'+imgHtml+'</div></a></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setBookmarkUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔖</div><div style="font-size:14px;color:var(--text-dim);">Click to add bookmark URL</div></div></div>`;
|
||||
var safeUrl=url.replace(/"/g,'"');
|
||||
return url?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark"><a href="${safeUrl}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;"><div style="display:flex;gap:14px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:0;background:var(--bg-secondary);"><div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">${title}</div>${descHtml}${siteHtml}</div>${imgHtml}</div></a></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setBookmarkUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔖</div><div style="font-size:14px;color:var(--text-dim);">Click to add bookmark URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='embed'){
|
||||
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
|
||||
@@ -569,13 +570,20 @@ window.__fdEditorScriptsLoaded = true;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='synced'){
|
||||
if(b._synced_deleted){
|
||||
// v6.5.0: source deleted → clear state instead of stale cache.
|
||||
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid #e05e5e;background:var(--bg-secondary);opacity:.8">
|
||||
<div class="synced-badge" style="font-size:11px;color:#e05e5e;padding:4px 8px;font-weight:600;">🔗 Deleted synced block</div>
|
||||
<div style="color:var(--text-dim);padding:0 8px 8px;font-size:12px;">The original synced block was deleted — this copy no longer updates.</div>
|
||||
</div>`;
|
||||
}
|
||||
var syncedTitle=b._synced_title||'Synced block';
|
||||
var syncedContent=b._synced_content||[];
|
||||
var sid=b.synced_id||'';
|
||||
var inner=syncedContent.map(function(ch){return'<div class="block-content" style="padding-left:16px;border-left:2px solid var(--accent);margin:4px 0;">'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'</div>';}).join('');
|
||||
var syncedInner=syncedContent.map(function(ch){return'<div class="block-content" style="padding-left:16px;border-left:2px solid var(--accent);margin:4px 0;">'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'</div>';}).join('');
|
||||
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid var(--accent);background:var(--synced-bg,var(--bg-secondary))">
|
||||
<div class="synced-badge" style="font-size:11px;color:var(--accent);padding:4px 8px;font-weight:600;">🔗 Synced — ${esc(syncedTitle)}</div>
|
||||
<div class="synced-content">${inner||'<div style="color:var(--text-dim);padding:8px;">Loading synced content…</div>'}</div>
|
||||
<div class="synced-content">${syncedInner||'<div style="color:var(--text-dim);padding:8px;">Loading synced content…</div>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
|
||||
@@ -1022,7 +1030,7 @@ window.__fdEditorScriptsLoaded = true;
|
||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||
pubAllowEdit:false,pubAllowComments:true,
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
||||
@@ -1658,15 +1666,30 @@ applyAIBlocks(text){
|
||||
},
|
||||
shareInvite() {
|
||||
var self = this;
|
||||
if (this.inviteGroupId) {
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ group_id: this.inviteGroupId, permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') {
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteGroupId = null;
|
||||
self.inviteUsers = []; self.inviteGroups = []; self.inviteSuggestOpen = false;
|
||||
self.showToast('Group shared'); self.loadShares();
|
||||
} else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
return;
|
||||
}
|
||||
if (!this.inviteEmail.trim()) return;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf2 = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 ? csrf2[1] : '' },
|
||||
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') {
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteSuggestOpen = false;
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteGroups = []; self.inviteSuggestOpen = false;
|
||||
self.showToast('Invitation sent'); self.loadShares();
|
||||
} else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
@@ -1674,7 +1697,7 @@ applyAIBlocks(text){
|
||||
inviteInput(){
|
||||
var self = this;
|
||||
var q = (this.inviteEmail || '').trim();
|
||||
if (!q) { this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteSel = -1; return; }
|
||||
if (!q) { this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteGroupId = null; this.inviteSel = -1; return; }
|
||||
clearTimeout(this._inviteTimer);
|
||||
this._inviteTimer = setTimeout(function(){ self._loadInviteUsers(q); }, 150);
|
||||
},
|
||||
@@ -1688,9 +1711,20 @@ applyAIBlocks(text){
|
||||
this.inviteUsers = (d.users || []).filter(function(u){
|
||||
return u.id !== me && !shared[(u.login || '').toLowerCase()];
|
||||
});
|
||||
// Groups: charger les groupes du workspace et filtrer
|
||||
try {
|
||||
const gr = await fetch('/api/v2/groups', { credentials: 'same-origin' });
|
||||
const gd = await gr.json();
|
||||
var sharedGroups = {};
|
||||
(this.accessList || []).forEach(function(a){ if (a.shared_with_group_id) sharedGroups[a.shared_with_group_id] = true; });
|
||||
var ql = (q || '').toLowerCase();
|
||||
this.inviteGroups = (gd.groups || []).filter(function(g){
|
||||
return !sharedGroups[g.id] && (!ql || (g.name || '').toLowerCase().indexOf(ql) >= 0);
|
||||
}).slice(0, 8);
|
||||
} catch(e2) { this.inviteGroups = []; }
|
||||
this.inviteSel = this.inviteUsers.length ? 0 : -1;
|
||||
this.inviteSuggestOpen = true;
|
||||
} catch(e) { this.inviteUsers = []; this.inviteSuggestOpen = false; }
|
||||
} catch(e) { this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; }
|
||||
},
|
||||
inviteMove(dir){
|
||||
var el = this.$refs.inviteSuggest;
|
||||
@@ -1712,8 +1746,17 @@ applyAIBlocks(text){
|
||||
},
|
||||
pickInviteUser(u){
|
||||
this.inviteUserId = u.id;
|
||||
this.inviteGroupId = null;
|
||||
this.inviteEmail = u.login || u.full_name || '';
|
||||
this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
var el = this.$refs.inviteInput;
|
||||
if (el) el.focus();
|
||||
},
|
||||
pickInviteGroup(g){
|
||||
this.inviteGroupId = g.id;
|
||||
this.inviteUserId = null;
|
||||
this.inviteEmail = g.name || '';
|
||||
this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
var el = this.$refs.inviteInput;
|
||||
if (el) el.focus();
|
||||
},
|
||||
|
||||
@@ -1100,6 +1100,47 @@
|
||||
this.loadAgents();
|
||||
this.initSidebarWidth();
|
||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||
this.startClipAutoRefresh();
|
||||
},
|
||||
// ── Auto-refresh sidebar after a web clip (no manual reload) ──
|
||||
_clipRefreshTimer: null,
|
||||
_clipRefreshInterval: null,
|
||||
startClipAutoRefresh() {
|
||||
const self = this;
|
||||
// Instant refresh when extension notifies (content script forwards event)
|
||||
window.addEventListener('flowdeck:clip-created', () => {
|
||||
clearTimeout(self._clipRefreshTimer);
|
||||
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
|
||||
});
|
||||
document.addEventListener('flowdeck:workspace-changed', () => {
|
||||
clearTimeout(self._clipRefreshTimer);
|
||||
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
|
||||
});
|
||||
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) self.refreshSidebarTree();
|
||||
});
|
||||
window.addEventListener('focus', () => self.refreshSidebarTree());
|
||||
// Polling fallback every 15s while tab is visible (covers extension clip without message)
|
||||
try {
|
||||
self._clipRefreshInterval = setInterval(() => {
|
||||
if (!document.hidden) self.refreshSidebarTree();
|
||||
}, 15000);
|
||||
} catch (_e) {}
|
||||
// BroadcastChannel cross-tab sync (all FlowDeck tabs refresh together)
|
||||
try {
|
||||
if (window.BroadcastChannel) {
|
||||
const bc = new BroadcastChannel('flowdeck');
|
||||
bc.onmessage = (ev) => {
|
||||
if (ev.data && ev.data.type === 'clip-created') self.refreshSidebarTree();
|
||||
};
|
||||
window._fdBC = bc;
|
||||
// Bridge DOM event → BroadcastChannel for extension-triggered clips
|
||||
window.addEventListener('flowdeck:clip-created', () => {
|
||||
try { bc.postMessage({ type: 'clip-created', at: Date.now() }); } catch (_e) {}
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
},
|
||||
agentList: [],
|
||||
loadAgents() {
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
@@ -420,6 +421,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions (Web Clipper) -->
|
||||
<div x-show="activeSection==='extensions'">
|
||||
<h2>Extensions</h2>
|
||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Web Clipper</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);line-height:1.5;margin-bottom:12px;">
|
||||
Install the <a href="/extensions" style="color:var(--accent);">browser extension</a> (Chrome/Firefox/Edge). After install, use <b>Verify & Connect</b> once or paste an <b>API token</b> in the extension popup.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<a href="/extensions" class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Get extension</a>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Download .zip</a>
|
||||
</div>
|
||||
<div x-show="!clipperDevices.length" class="setting-desc" style="padding:8px 0;">No extension connected yet — clip your first page to appear here.</div>
|
||||
<template x-for="d in clipperDevices" :key="d.id">
|
||||
<div class="setting-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="setting-label">
|
||||
<span x-text="(d.device_name || d.extension_name || 'Clipper') + ' — ' + d.device_id.slice(0,24)"></span>
|
||||
<span class="llm-badge" :class="d.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="d.revoked ? 'Révoqué' : 'Actif'"></span>
|
||||
<span class="llm-badge warn" style="margin-left:6px;" x-text="d.extension_name"></span>
|
||||
</div>
|
||||
<div class="setting-desc" style="font-size:11px;">
|
||||
<span x-text="'Connecté: ' + (d.created_at||'').slice(0,16)"></span>
|
||||
<span x-show="d.last_used_at" x-text="' · Dernier: ' + d.last_used_at.slice(0,16)"></span>
|
||||
<span x-text="' · ' + d.clips_count + ' clipp(s)'"></span>
|
||||
<span x-show="d.last_clip_at" x-text="' · Dernier clip: ' + d.last_clip_at.slice(0,16)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeClipperDevice(d)" x-show="!d.revoked">Révoquer</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<h3>Tester le clipper (sans extension)</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);">Envoyez un clip тест direct via l'API :</p>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<input class="settings-input" x-model="clipTestUrl" placeholder="https://example.com/article" style="flex:1;">
|
||||
<button class="btn btn-primary" @click="testClip()" :disabled="clipTesting" x-text="clipTesting ? 'Envoi…' : 'Clip URL'"></button>
|
||||
</div>
|
||||
<div x-show="clipTestMsg" style="margin-top:8px;font-size:12px;" :style="{color: clipTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}" x-text="clipTestMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div x-show="activeSection==='workspace'">
|
||||
<h2>Workspace</h2>
|
||||
@@ -1056,6 +1103,12 @@ function settingsInit() {
|
||||
newToken: null,
|
||||
tokenName: '',
|
||||
sessions: [],
|
||||
// v6.2.0 Web Clipper
|
||||
clipperDevices: [],
|
||||
clipTestUrl: 'https://example.com',
|
||||
clipTesting: false,
|
||||
clipTestMsg: '',
|
||||
clipTestOk: false,
|
||||
// v5.2.0 Backups (admin)
|
||||
backups: [],
|
||||
backupRunning: false,
|
||||
@@ -1853,6 +1906,35 @@ function settingsInit() {
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
// ── v6.2.0 Web Clipper ──
|
||||
async loadClipperDevices() {
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/devices', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.clipperDevices = d.devices || [];
|
||||
} catch(e) { this.clipperDevices = []; }
|
||||
},
|
||||
async revokeClipperDevice(d) {
|
||||
if (!confirm('Révoquer l\'extension « ' + (d.device_name || d.device_id) + ' » ?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/devices/' + d.id, {method:'DELETE', credentials:'same-origin', headers: {'X-CSRF-Token': this.getCsrfToken()}});
|
||||
if (r.ok) await this.loadClipperDevices();
|
||||
} catch(e) {}
|
||||
},
|
||||
async testClip() {
|
||||
this.clipTesting = true; this.clipTestMsg = ''; this.clipTestOk = false;
|
||||
var url = (this.clipTestUrl || '').trim();
|
||||
if (!url) { this.clipTestMsg = 'URL requise'; this.clipTesting = false; return; }
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/clip', {method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({url: url, title: 'Test clipper — ' + url, content: '<html><body><h1>Test clip</h1><p>Contenu de test depuis Settings.</p></body></html>', content_type: 'article', device_id: 'settings-test'})});
|
||||
var d = await r.json();
|
||||
if (r.ok) { this.clipTestOk = true; this.clipTestMsg = '✓ Page créée : #' + d.page_id + ' — ' + d.title; await this.loadClipperDevices(); }
|
||||
else this.clipTestMsg = d.detail || 'Erreur';
|
||||
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
|
||||
finally { this.clipTesting = false; }
|
||||
},
|
||||
// ── v5.2.0 Backups (admin) ──
|
||||
async loadBackups() {
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Guide des API FlowDeck — Référence d'implémentation v6.0.0
|
||||
# Guide des API FlowDeck — Référence d'implémentation v6.3.0
|
||||
|
||||
> **Statut** : référence de conception pour la mise en place de l'API publique complète (v6.0.0).
|
||||
> **Dernière mise à jour** : 2026-09-04
|
||||
> **Statut** : ✅ **IMPLÉMENTÉ (v6.3.0, 2026-09-21)** — l'API publique `/api/v2` est livrée :
|
||||
> routeur `app/routers/api_v2.py`, helpers `app/services/api_v2_helpers.py`, migration 20,
|
||||
> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json` — 402 chemins), 24 tests dédiés.
|
||||
> Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception.
|
||||
> **Dernière mise à jour** : 2026-09-21
|
||||
> **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation.
|
||||
>
|
||||
> **Reste reporté (v6.4)** : webhooks HMAC `X-FlowDeck-Signature` + retry 2s/10s/60s + événements étendus ;
|
||||
> migration de `/api/v2/sync` vers Bearer ; `REST /api/v2/projects/*/file|commits|issues` complets via `ForgeAdapter`.
|
||||
|
||||
---
|
||||
|
||||
@@ -358,7 +364,7 @@ Endpoints publics v2 :
|
||||
|---------|-------|-------------|
|
||||
| GET | `/api/v2/collections/{id}/pages?filter[status]=…&sort=…` | Lister (pagination, filtres, tri, fields) |
|
||||
| POST | `/api/v2/collections/{id}/pages` | Créer (title, icon, property_values, parent_id) |
|
||||
| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) |
|
||||
| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) — v6.5.0 : + `content_page_id` (page contenu de la ligne, `null` si absente, sans création lazy) |
|
||||
| PATCH | `/api/v2/pages/{id}` | Titre, icon, position, property_values (merge) |
|
||||
| DELETE | `/api/v2/pages/{id}` | Corbeille (soft delete via `deleted_at`) |
|
||||
| POST | `/api/v2/pages/{id}/restore` | Restaurer |
|
||||
@@ -669,19 +675,21 @@ EVENTS = [
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist d'implémentation v6.0.0 (ordre recommandé)
|
||||
## 9. Checklist d'implémentation v6.3.0 — état livré
|
||||
|
||||
1. **Migration DB** : table `api_tokens` (+ index sur token_hash), `webhook_deliveries`, `api_audit_log`, FTS5 index de recherche.
|
||||
2. **`PermissionManager`** (`app/services/permission_manager.py`) — prerequisite de toute la v2 : `can_read/can_write/can_admin` par workspace + collection + page.
|
||||
3. **Conventions** : handler d'erreurs RFC 7807, convertisseur ISO-8601, helper pagination (`paginate(query, limit, offset)`), dépendances `require_scope`.
|
||||
4. **Wrappers v2 read** : reprendre `public_api.py` → `/api/v2` avec pagination/filtres (collections, pages, my-tasks).
|
||||
5. **Wrappers v2 write** : collections, pages, properties, views (mutation) — les plus demandés par les intégrations.
|
||||
6. **Webhooks v2** : CRUD abonnements + déliveries + retry + signature HMAC + événements étendus.
|
||||
7. **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
|
||||
8. **Recherche FTS** (`/api/v2/search`).
|
||||
9. **OpenAPI** : activer /docs + générer openapi-v2.json + exemples.
|
||||
10. **Tests** (`tests/test_public_api_v2.py`) : auth token + scopes, CRUD complet par ressource, pagination, erreurs, rate limit, webhook delivery (mock httpx). Cible : couverture ≥ 80 % sur le routeur v2.
|
||||
11. **Documentation** utilisateur : page `/help` + ce guide référencé depuis le README.
|
||||
1. ✅ **Migration DB** (migration 20) : `api_tokens.scopes/expires_at`, `webhook_deliveries`, `api_audit_log`, `idempotency_keys` ; FTS5 déjà en migration 3.
|
||||
2. ✅ **`PermissionManager`** (`app/services/permission_manager.py`) — héritage workspace/collection/page + groupes.
|
||||
3. ✅ **Conventions** : handler RFC 7807 (sur `StarletteHTTPException`), ISO-8601, `parse_pagination()` + `X-Total-Count`, `require_scope()` hiérarchique.
|
||||
4. ✅ **Wrappers v2 read** : collections, pages, my-tasks, search — pagination/filtres/tri/fields.
|
||||
5. ✅ **Wrappers v2 write** : collections, pages, properties, views (+ dashboards, comments, notifications, favoris, tags, partage, historique, sprints, templates, export/import, workspaces, admin).
|
||||
6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements.
|
||||
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
|
||||
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
|
||||
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` généré (402 chemins).
|
||||
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
|
||||
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
|
||||
|
||||
> **Reporté v6.4** : webhooks HMAC/retry/events étendus ; `/api/v2/sync` en Bearer ; endpoints forges `file/commits/issues` complets via `ForgeAdapter` ; rate limit distribué (Redis).
|
||||
|
||||
## 10. Références
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
# V6.0.0 — Permissions Granulaires : Page-level & Property-level Access Control
|
||||
|
||||
> **Statut** : Conception détaillée — v6.0.0
|
||||
> **Date** : 2026-09-15
|
||||
> **Route** : `feat/v6-permissions` → `develop` → `main`
|
||||
> **Dépendances** : v4.0.0 Accounts & Integrations + v2.0 Multi-utilisateurs (workspaces, workspace_members)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & objectifs
|
||||
|
||||
Étendre le modèle de permissions existant (workspace-level avec rôles owner/admin/editor/commenter/viewer) vers des permissions **plus fines** :
|
||||
|
||||
- **Page-level** : contrôler qui peut voir, éditer, commenter une page spécifique
|
||||
- **Property-level** : contrôler quels utilisateurs peuvent voir ou modifier des propriétés spécifiques
|
||||
- **Collection-level** : permissions héritables ou individuelles sur une collection/database
|
||||
|
||||
### Objectifs
|
||||
|
||||
| Critère | Cible |
|
||||
|---------|-------|
|
||||
| Héritage | Page-level → Collection-level → Workspace-level |
|
||||
| Performance | Vérification de permission < 10 ms (cache) |
|
||||
| Compatibilité | Tous les systèmes existants continuent de fonctionner |
|
||||
| UI | Indicateurs clairs des permissions (icônes, masquage) |
|
||||
| Audit | Log complet de tous les changements de permissions |
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle de permissions étendu
|
||||
|
||||
### 2.1 Hiérarchie des permissions
|
||||
|
||||
```
|
||||
Workspace (owner/admin/editor/commenter/viewer)
|
||||
└── Collection (hérite du workspace + override possible)
|
||||
└── Page (hérite de la collection + override possible)
|
||||
└── Block (hérite de la page + override possible)
|
||||
└── Property (hérite de la page/collection + override possible)
|
||||
```
|
||||
|
||||
### 2.2 Matrice des permissions
|
||||
|
||||
| Permission | workspace | collection | page | property |
|
||||
|------------|-----------|------------|------|----------|
|
||||
| **View** | ✓ viewer+ | ✓ viewer+ | ✓ viewer+ | ✓ viewer+ |
|
||||
| **Create** | ✓ editor+ | ✓ editor+ | ✓ editor+ | N/A |
|
||||
| **Edit** | ✓ editor+ | ✓ editor+ | ✓ editor+ | ✓ editor+ |
|
||||
| **Comment** | ✓ commenter+ | ✓ commenter+ | ✓ commenter+ | N/A |
|
||||
| **Delete** | ✓ admin+ | ✓ admin+ | ✓ admin+ | ✓ admin+ |
|
||||
| **Manage permissions** | ✓ admin+ | ✓ admin+ | ✓ admin+ | ✓ admin+ |
|
||||
| **Share** | ✓ editor+ | ✓ editor+ | ✓ editor+ | N/A |
|
||||
| **Lock** | ✓ admin+ | ✓ admin+ | ✓ admin+ | N/A |
|
||||
|
||||
### 2.3 Rôles par ressource
|
||||
|
||||
```python
|
||||
# Rôles disponibles par ressource (au-delà du workspace)
|
||||
RESOURCE_ROLES = {
|
||||
"page": {
|
||||
"viewer": "Peut lire la page",
|
||||
"commenter": "Peut lire + commenter",
|
||||
"editor": "Peut lire + éditer",
|
||||
"owner": "Peut tout faire + gérer les permissions",
|
||||
},
|
||||
"property": {
|
||||
"viewer": "Peut lire la propriété",
|
||||
"editor": "Peut modifier la propriété",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tables de base de données
|
||||
|
||||
### 3.1 Nouveau table `page_permissions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE page_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Soit user_id, soit group_id, soit role (pour rôle global)
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- 'viewer', 'commenter', 'editor', 'owner'
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
-- 'explicit' (direct user), 'group', 'workspace_role'
|
||||
inherited BOOLEAN NOT NULL DEFAULT 0,
|
||||
-- True = hérité de la collection, pas de permission explicite
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, user_id, group_id) -- Une permission par utilisateur/groupe
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pp_page ON page_permissions(page_id, role);
|
||||
CREATE INDEX idx_pp_user ON page_permissions(user_id);
|
||||
```
|
||||
|
||||
### 3.2 Nouveau table `collection_permissions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE collection_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
inherited BOOLEAN NOT NULL DEFAULT 0,
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, user_id, group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cp_collection ON collection_permissions(collection_id, role);
|
||||
```
|
||||
|
||||
### 3.3 Nouveau table `property_permissions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE property_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- 'viewer', 'editor'
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
inherited BOOLEAN NOT NULL DEFAULT 0,
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, property_id, user_id, group_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.4 Nouveau table `user_groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.5 Extension des tables existantes
|
||||
|
||||
```sql
|
||||
-- Ajout de colonnes de permission sur les tables principales
|
||||
ALTER TABLE collection_pages ADD COLUMN permission_type TEXT DEFAULT 'inherit';
|
||||
-- 'inherit' (default) | 'restricted' | 'private'
|
||||
|
||||
ALTER TABLE collections ADD COLUMN permission_type TEXT DEFAULT 'inherit';
|
||||
-- 'inherit' | 'restricted' | 'private'
|
||||
```
|
||||
|
||||
### 3.6 Table d'audit
|
||||
|
||||
```sql
|
||||
CREATE TABLE permission_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_type TEXT NOT NULL, -- 'page', 'collection', 'property', 'group'
|
||||
resource_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL, -- 'grant', 'revoke', 'inherit', 'inherit_override'
|
||||
target_user_id INTEGER,
|
||||
target_group_id INTEGER,
|
||||
old_role TEXT,
|
||||
new_role TEXT,
|
||||
performed_by INTEGER REFERENCES users(id),
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_paudit_resource ON permission_audit_log(resource_type, resource_id, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints API
|
||||
|
||||
### 4.1 Routeur `app/routers/permissions.py`
|
||||
|
||||
```
|
||||
GET /api/v2/pages/{id}/permissions — Lister les permissions d'une page
|
||||
POST /api/v2/pages/{id}/permissions — Accorder une permission
|
||||
DELETE /api/v2/pages/{id}/permissions/{id} — Révoquer une permission
|
||||
POST /api/v2/pages/{id}/permissions/batch — Batch de permissions
|
||||
GET /api/v2/collections/{id}/permissions — Lister les permissions d'une collection
|
||||
POST /api/v2/collections/{id}/permissions — Accorder une permission
|
||||
DELETE /api/v2/collections/{id}/permissions/{id} — Révoquer
|
||||
GET /api/v2/properties/{id}/permissions — Lister les permissions d'une propriété
|
||||
POST /api/v2/properties/{id}/permissions — Accorder
|
||||
DELETE /api/v2/properties/{id}/permissions/{id} — Révoquer
|
||||
GET /api/v2/groups — Lister les groupes du workspace
|
||||
POST /api/v2/groups — Créer un groupe
|
||||
PUT /api/v2/groups/{id} — Mettre à jour un groupe
|
||||
DELETE /api/v2/groups/{id} — Supprimer un groupe
|
||||
POST /api/v2/groups/{id}/members — Ajouter un membre au groupe
|
||||
DELETE /api/v2/groups/{id}/members/{uid} — Retirer un membre
|
||||
GET /api/v2/audit/permissions — Historique des changements de permissions
|
||||
```
|
||||
|
||||
### 4.2 Service `app/services/permission_manager.py` — Extension
|
||||
|
||||
Le `PermissionManager` existant est étendu :
|
||||
|
||||
```python
|
||||
class PermissionManager:
|
||||
"""Granular permission resolution — héritage workspace → collection → page → property."""
|
||||
|
||||
# ── Méthodes existantes (conservées) ──
|
||||
def role_in_workspace(self, workspace_id: int) -> str: ...
|
||||
def can_read(self, workspace_id: int) -> bool: ...
|
||||
def can_write(self, workspace_id: int) -> bool: ...
|
||||
|
||||
# ── Méthodes nouvelles — Page-level ──
|
||||
|
||||
def get_page_permission(self, page_id: int, user_id: int) -> str:
|
||||
"""Retourne le rôle effectif pour une page ('viewer', 'commenter', 'editor', 'owner', None).
|
||||
Résout l'héritage : page → collection → workspace."""
|
||||
|
||||
def can_view_page(self, page_id: int, user_id: int) -> bool:
|
||||
"""Peut voir la page ?"""
|
||||
|
||||
def can_edit_page(self, page_id: int, user_id: int) -> bool:
|
||||
"""Peut éditer la page ?"""
|
||||
|
||||
def can_comment_page(self, page_id: int, user_id: int) -> bool:
|
||||
"""Peut commenter la page ?"""
|
||||
|
||||
def can_manage_page_permissions(self, page_id: int, user_id: int) -> bool:
|
||||
"""Peut gérer les permissions de la page ?"""
|
||||
|
||||
# ── Méthodes nouvelles — Collection-level ──
|
||||
|
||||
def get_collection_permission(self, collection_id: int, user_id: int) -> str:
|
||||
"""Rôle effectif pour une collection."""
|
||||
|
||||
def can_view_collection(self, collection_id: int, user_id: int) -> bool: ...
|
||||
def can_edit_collection(self, collection_id: int, user_id: int) -> bool: ...
|
||||
|
||||
# ── Méthodes nouvelles — Property-level ──
|
||||
|
||||
def can_view_property(self, collection_id: int, property_id: int, user_id: int) -> bool:
|
||||
"""Peut voir cette propriété ?"""
|
||||
|
||||
def can_edit_property(self, collection_id: int, property_id: int, user_id: int) -> bool:
|
||||
"""Peut éditer cette propriété ?"""
|
||||
|
||||
def get_visible_properties(self, collection_id: int, user_id: int) -> list[int]:
|
||||
"""Retourne la liste des IDs de propriétés visibles pour l'utilisateur."""
|
||||
|
||||
# ── Méthodes nouvelles — Groups ──
|
||||
|
||||
def create_group(self, workspace_id: int, name: str, created_by: int) -> int: ...
|
||||
def add_user_to_group(self, group_id: int, user_id: int, added_by: int) -> None: ...
|
||||
def get_groups_for_workspace(self, workspace_id: int) -> list[dict]: ...
|
||||
|
||||
# ── Méthodes nouvelles — Audit ──
|
||||
|
||||
def log_permission_change(self, resource_type: str, resource_id: int,
|
||||
action: str, target_user_id: int | None,
|
||||
target_group_id: int | None,
|
||||
old_role: str | None, new_role: str | None,
|
||||
performed_by: int) -> None: ...
|
||||
|
||||
# ── Méthodes nouvelles — Résolution rapide ──
|
||||
|
||||
def _resolve_page_effective_role(self, page_id: int, user_id: int) -> str:
|
||||
"""Résout le rôle effectif en parcourant la chaîne d'héritage.
|
||||
Cache le résultat pendant 60s."""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Logique de résolution des permissions
|
||||
|
||||
### 5.1 Algorithme de résolution
|
||||
|
||||
```python
|
||||
def resolve_effective_permission(resource_type: str, resource_id: int, user_id: int) -> str:
|
||||
"""
|
||||
Résout la permission effective en parcourant la chaîne d'héritage.
|
||||
Règle : la permission la plus restrictive prévaut (principe du moindre privilège).
|
||||
"""
|
||||
|
||||
if resource_type == "page":
|
||||
# 1. Vérifier page_permissions directe
|
||||
direct = query_page_permission(page_id, user_id)
|
||||
if direct and not direct.inherited:
|
||||
return direct.role
|
||||
|
||||
# 2. Remonter à la collection
|
||||
collection_id = get_page_collection(page_id)
|
||||
coll_perm = query_collection_permission(collection_id, user_id)
|
||||
if coll_perm and not coll_perm.inherited:
|
||||
return coll_perm.role
|
||||
|
||||
# 3. Remonter au workspace
|
||||
ws_id = get_collection_workspace(collection_id)
|
||||
return get_workspace_role(ws_id, user_id)
|
||||
|
||||
elif resource_type == "property":
|
||||
# Même logique : property → collection → workspace
|
||||
...
|
||||
|
||||
# Fallback
|
||||
return "viewer" # Tout utilisateur authentifié a au moins le rôle viewer
|
||||
```
|
||||
|
||||
### 5.2 Principe du moindre privilège
|
||||
|
||||
- Si un utilisateur a `editor` au niveau workspace mais `viewer` sur une page spécifique → il est **viewer** sur cette page
|
||||
- Si un utilisateur a `viewer` au niveau workspace et aucune permission explicite sur la page → il est **viewer** sur cette page
|
||||
- Les permissions explicites de page **écrasent** toujours l'héritage
|
||||
|
||||
### 5.3 Cache de résolution
|
||||
|
||||
```python
|
||||
# Cache LRU pour les résolutions de permissions (60s TTL)
|
||||
from functools import lru_cache
|
||||
|
||||
class PermissionResolver:
|
||||
@lru_cache(maxsize=1024)
|
||||
def cached_resolve(self, page_id: int, user_id: int, timestamp: int) -> str:
|
||||
# Le timestamp force l'invalidation du cache toutes les 60s
|
||||
return self.resolve_effective_permission(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface utilisateur
|
||||
|
||||
### 6.1 Page editor — Bouton de permissions
|
||||
|
||||
Dans `page_editor.html`, un nouveau bouton **"Permissions"** (icône 🔒) dans le menu `...` :
|
||||
|
||||
```html
|
||||
<div class="page-permissions-panel" x-show="showPermissions" x-transition>
|
||||
<h3>Permissions de la page</h3>
|
||||
|
||||
<div class="perm-section">
|
||||
<h4>Accès direct</h4>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Utilisateur/Groupe</th>
|
||||
<th>Rôle</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<template x-for="perm in pagePermissions">
|
||||
<tr>
|
||||
<td x-text="perm.name"></td>
|
||||
<td>
|
||||
<select x-model="perm.role" @change="updatePermission(perm)">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="commenter">Commenter</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><button @click="revokePermission(perm.id)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
<button @click="addPermission()">+ Ajouter un accès</button>
|
||||
</div>
|
||||
|
||||
<div class="perm-section">
|
||||
<h4>Groupes</h4>
|
||||
<template x-for="group in groups">
|
||||
<div class="perm-group-row">
|
||||
<span x-text="group.name"></span>
|
||||
<select x-model="group.role" @change="updateGroupPermission(group)">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<button @click="createGroup()">+ Nouveau groupe</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6.2 Collection-level permissions
|
||||
|
||||
Dans `collections.py` router et `_database_table_scripts.html` :
|
||||
- Nouveau bouton **"Permissions"** dans le menu de la collection
|
||||
- Panneau latéral avec la même logique que les pages
|
||||
- Indication visuelle : icône 🔒 sur les pages en "restricted"
|
||||
|
||||
### 6.3 Property-level — Masquage dans les vues
|
||||
|
||||
```javascript
|
||||
// Dans la vue table/kanban/gallery :
|
||||
// Les propriétés restreintes sont masquées ou verrouillées
|
||||
if (!canViewProperty(propertyId, currentUser)) {
|
||||
// Masquer la colonne entière
|
||||
// OU afficher une placeholder "🔒 Restricted"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Indicateurs visuels
|
||||
|
||||
| Situation | Indicateur |
|
||||
|-----------|-----------|
|
||||
| Page restreinte (permission différente du workspace) | Icône 🔒 dans le sidebar à côté du titre |
|
||||
| Propriété masquée | Colonne invisible ou placeholder 🔒 |
|
||||
| Utilisateur sans accès | Page affiche "Vous n'avez pas accès à cette page" |
|
||||
| Propriété en lecture seule | Champ désactivé avec tooltip "Permissions insuffisantes" |
|
||||
|
||||
---
|
||||
|
||||
## 7. Endpoints existants — Modifications nécessaires
|
||||
|
||||
### 7.1 Protection dans les routeurs existants
|
||||
|
||||
Les routeurs suivants doivent intégrer les vérifications de permissions granulaires :
|
||||
|
||||
```python
|
||||
# board.py — pages
|
||||
@router.get("/board/api/pages/{id}")
|
||||
async def get_page(id: int, request: Request):
|
||||
user = get_current_user(request)
|
||||
if not permission_manager.can_view_page(id, user["id"]):
|
||||
raise HTTPException(404, "Page not found") # 404 et non 403 pour la sécurité
|
||||
...
|
||||
|
||||
# collections.py — pages de collection
|
||||
@router.post("/db/{id}/pages/api")
|
||||
async def create_page(id: int, ...):
|
||||
user = get_current_user(request)
|
||||
if not permission_manager.can_edit_collection(id, user["id"]):
|
||||
raise HTTPException(403, "Insufficient permissions")
|
||||
...
|
||||
```
|
||||
|
||||
### 7.2 Propriétés visibles dans les vues
|
||||
|
||||
```python
|
||||
# Dans les vues (table, board, calendar, etc.) :
|
||||
def get_visible_collection_data(collection_id, user_id):
|
||||
"""Retourne seulement les propriétés visibles pour l'utilisateur."""
|
||||
visible_props = permission_manager.get_visible_properties(collection_id, user_id)
|
||||
# Filtrer la réponse API et le rendu HTML
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
| Test | Description | Outil |
|
||||
|------|-------------|-------|
|
||||
| Page permission grant | Accorder viewer à un utilisateur → il peut voir | Unit test |
|
||||
| Page permission revoke | Révoquer → 404 sur la page | Unit test |
|
||||
| Inheritance | Workspace editor + page viewer → viewer sur la page | Unit test |
|
||||
| Property hide | Propriété restreinte → colonne masquée | Integration test |
|
||||
| Group permissions | Groupe editor → tous les membres editor | Unit test |
|
||||
| Batch permissions | 10+ permissions en un POST | Unit test |
|
||||
| Audit log | Chaque changement de permission logué | Unit test |
|
||||
| Performance | 1000 utilisateurs, résolution < 10ms | Benchmark |
|
||||
| Conflict | Deux admins modifient les mêmes permissions → dernière écriture gagne | Integration test |
|
||||
| Public page | Page partagée publiquement ignore les restrictions | Unit test |
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist d'implémentation
|
||||
|
||||
1. **Migrations DB** — `page_permissions`, `collection_permissions`, `property_permissions`, `user_groups`, `group_members`, `permission_audit_log`, colonnes `permission_type`
|
||||
2. **`app/services/permission_manager.py`** — extension complète (résolution héritage, cache, groupes)
|
||||
3. **`app/routers/permissions.py`** — endpoints CRUD permissions
|
||||
4. **Protection dans les routeurs existants** (`board.py`, `collections.py`, `api.py`, `workspace.py`)
|
||||
5. **UI — Page permissions panel** (extension `page_editor.html`)
|
||||
6. **UI — Collection permissions panel** (extension `collections.py`)
|
||||
7. **UI — Property visibility in views** (`_database_table_scripts.html`)
|
||||
8. **UI — Groups management** (settings page)
|
||||
9. **Audit log** — tous les changements de permissions
|
||||
10. **Tests** — tous les scénarios de permissions
|
||||
11. **Documentation** — `/help` section permissions granulaires
|
||||
12. **Performance** — cache LRU + index DB optimaux
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance
|
||||
|
||||
| Opération | Sans cache | Avec cache (LRU 60s) |
|
||||
|-----------|-----------|---------------------|
|
||||
| Résolution permission page | ~15 ms (3 requêtes DB) | ~0.5 ms |
|
||||
| Liste des propriétés visibles | ~20 ms | ~1 ms |
|
||||
| Vérification page dans sidebar | ~10 ms | ~0.3 ms |
|
||||
| Batch permissions (10 items) | ~50 ms | ~5 ms |
|
||||
|
||||
---
|
||||
|
||||
## 11. Références
|
||||
|
||||
- `app/services/permission_manager.py` — PermissionManager existant (base)
|
||||
- `app/routers/board.py` — Protection de pages à étendre
|
||||
- `app/routers/collections.py` — Protection de collections à étendre
|
||||
- `app/routers/workspace.py` — Gestion des membres existants
|
||||
- `app/db.py` — Schéma de base de données
|
||||
- `docs/API_GUIDE_V6.md` — Référence API v2 (endpoints permissions)
|
||||
- `ROADMAP.md` — v6.0.0 Granular permissions item
|
||||
- [Notion API Permissions](https://developers.notion.com/docs/permissions)
|
||||
- [Google ACL Model](https://cloud.google.com/iam/docs/overview)
|
||||
@@ -0,0 +1,472 @@
|
||||
# V6.0.0 — SSO / SAML : Enterprise Authentication
|
||||
|
||||
> **Statut** : Conception détaillée — v6.0.0
|
||||
> **Date** : 2026-09-15
|
||||
> **Route** : `feat/v6-sso-saml` → `develop` → `main`
|
||||
> **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & objectifs
|
||||
|
||||
Ajouter le support **SSO/SAML 2.0** pour permettre aux entreprises d'intégrer FlowDeck dans leur infrastructure d'authentification existante. Les utilisateurs d'une organisation peuvent se connecter via leur fournisseur d'identité (IdP) d'entreprise sans avoir de compte local séparé.
|
||||
|
||||
### Objectifs
|
||||
|
||||
| Critère | Cible |
|
||||
|---------|-------|
|
||||
| Protocoles | SAML 2.0 (principal), OIDC (complémentaire) |
|
||||
| Fournisseurs supportés | Azure AD, Okta, Google Workspace, OneLogin, Keycloak, Auth0 |
|
||||
| Temps de connexion SSO | < 3 s (redirect + callback) |
|
||||
| Gestion des utilisateurs | Auto-provisioning à la première connexion |
|
||||
| Déconnexion | SLO (Single Logout) supporté |
|
||||
| Fallback | Login local toujours disponible (admin activé/désactivable) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture SSO/SAML
|
||||
|
||||
### 2.1 Flux SAML 2.0
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Utilisateur │ │ FlowDeck │ │ IdP │
|
||||
│ (navigateur) │ │ (SP) │ │ (Azure AD / │
|
||||
│ │ │ │ │ Okta / etc.)│
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
│ 1. Accède à FlowDeck │ │
|
||||
│───────────────────────►│ │
|
||||
│ │ │
|
||||
│ 2. Redirige vers IdP │ │
|
||||
│◄───────────────────────│ │
|
||||
│ │ │
|
||||
│ 3. SSO redirect │ │
|
||||
│───────────────────────►│────────────────────────►│
|
||||
│ │ │
|
||||
│ 5. Callback avec │ │
|
||||
│ SAMLResponse │◄────────────────────────│
|
||||
│ │ │
|
||||
│ 6. POST /auth/saml/callback │
|
||||
│───────────────────────►│ │
|
||||
│ │ │
|
||||
│ 7. Validate SAML │ │
|
||||
│ → Create/Update │ │
|
||||
│ user → Session │ │
|
||||
│◄───────────────────────│ │
|
||||
│ │ │
|
||||
│ 8. Authentifié │ │
|
||||
│◄───────────────────────│ │
|
||||
```
|
||||
|
||||
### 2.2 Composants côté serveur
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ FASTAPI (Python) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/routers/sso.py │ │
|
||||
│ │ ├─ GET /auth/saml/login — initie la requête │ │
|
||||
│ │ ├─ POST /auth/saml/callback — traite la réponse │ │
|
||||
│ │ ├─ GET /auth/saml/metadata — SP metadata endpoint │ │
|
||||
│ │ ├─ GET /auth/oidc/login — flux OIDC │ │
|
||||
│ │ ├─ POST /auth/oidc/callback │ │
|
||||
│ │ └─ POST /auth/sso/logout — SLO │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/providers/saml_provider.py │ │
|
||||
│ │ ├─ SAMLProvider — wrapper python3-saml │ │
|
||||
│ │ └─ validate_saml_response() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/providers/oidc_provider.py │ │
|
||||
│ │ ├─ OIDCProvider — wrapper httpx + OIDC lib │ │
|
||||
│ │ └─ validate_oidc_token() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/session.py — SessionManager étendu │ │
|
||||
│ │ ├─ create_sso_session() │ │
|
||||
│ │ ├─ handle_sso_user() │ │
|
||||
│ │ └─ enforce_sso_restriction() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ services/sso_provisioning.py │ │
|
||||
│ │ ├─ auto_provision_user() │ │
|
||||
│ │ ├─ sync_user_attributes() │ │
|
||||
│ │ └─ map_sso_groups_to_workspaces() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration SAML
|
||||
|
||||
### 3.1 Nouveau table `sso_config`
|
||||
|
||||
```sql
|
||||
CREATE TABLE sso_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
-- NULL = workspace-level; specific workspace; global if NULL+global flag
|
||||
provider_type TEXT NOT NULL,
|
||||
-- 'saml', 'oidc'
|
||||
entity_id TEXT NOT NULL,
|
||||
-- SAML: Entity ID of the IdP (e.g., "http://www.microsoft.com/...")
|
||||
sso_url TEXT NOT NULL,
|
||||
-- SAML: IdP SSO URL (HTTP-POST binding)
|
||||
slo_url TEXT,
|
||||
-- SAML: IdP Single Logout URL
|
||||
x509_certificate TEXT NOT NULL,
|
||||
-- SAML: IdP signing certificate (PEM format)
|
||||
-- OIDC: issuer URL
|
||||
issuer_url TEXT,
|
||||
-- OIDC: OIDC issuer identifier
|
||||
client_id TEXT,
|
||||
-- OIDC: client_id
|
||||
client_secret TEXT,
|
||||
-- OIDC: client_secret (encrypted at rest)
|
||||
scope TEXT DEFAULT 'openid profile email',
|
||||
-- OIDC: scopes requested
|
||||
attribute_mapping TEXT NOT NULL DEFAULT '{}',
|
||||
-- JSON: {"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", ...}
|
||||
-- Maps SAML/OIDC attributes to FlowDeck user fields
|
||||
auto_provision BOOLEAN NOT NULL DEFAULT 1,
|
||||
-- Auto-create user on first SSO login
|
||||
default_workspace_id INTEGER,
|
||||
-- Workspace to assign new SSO users
|
||||
groups_mapping TEXT DEFAULT '[]',
|
||||
-- JSON: [{"sso_group": "Admins", "workspace_role": "admin"}, ...]
|
||||
active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.2 Mapping des attributs
|
||||
|
||||
Par défaut pour SAML (mappings standard OASIS) :
|
||||
|
||||
```json
|
||||
{
|
||||
"login": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"email": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"full_name": "urn:oasis:names:tc:SAML:attribute:displayName",
|
||||
"avatar_url": "urn:oasis:names:tc:SAML:attribute:thumbnail"
|
||||
}
|
||||
```
|
||||
|
||||
Par défaut pour OIDC :
|
||||
|
||||
```json
|
||||
{
|
||||
"login": "sub",
|
||||
"email": "email",
|
||||
"full_name": "name",
|
||||
"avatar_url": "picture"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Configuration `.env` (exemples)
|
||||
|
||||
```env
|
||||
# SAML — Azure AD
|
||||
SSO_PROVIDER=saml
|
||||
SSO_ENTITY_ID=https://sts.windows.net/{tenant-id}/
|
||||
SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2
|
||||
SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout
|
||||
SSO_X509_CERT="-----BEGIN CERTIFICATE-----\n..."
|
||||
SSO_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"}
|
||||
SSO_AUTO_PROVISION=true
|
||||
SSO_DEFAULT_WORKSPACE_ID=1
|
||||
SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin"}]
|
||||
|
||||
# OIDC — Google Workspace
|
||||
SSO_PROVIDER=oidc
|
||||
SSO_ISSUER_URL=https://accounts.google.com
|
||||
SSO_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||
SSO_CLIENT_SECRET=yyy
|
||||
SSO_SCOPE=openid profile email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints API
|
||||
|
||||
### 4.1 Routeur `app/routers/sso.py`
|
||||
|
||||
```
|
||||
GET /auth/saml/login — Redirige vers l'IdP SAML
|
||||
POST /auth/saml/callback — Traite le SAMLResponse, crée la session
|
||||
GET /auth/saml/metadata — Retourne le SP metadata XML (pour configurer l'IdP)
|
||||
POST /auth/saml/logout — Initie le SLO (Single Logout)
|
||||
GET /auth/oidc/login — Redirige vers l'IdP OIDC
|
||||
POST /auth/oidc/callback — Traite le token OIDC, crée la session
|
||||
POST /auth/oidc/logout — SLO OIDC
|
||||
POST /api/v2/sso/config — Créer/configurer SSO (admin, scope admin)
|
||||
GET /api/v2/sso/config — Lire la config SSO courante
|
||||
PUT /api/v2/sso/config — Mettre à jour la config
|
||||
DELETE /api/v2/sso/config — Supprimer la config SSO (désactiver SSO)
|
||||
GET /api/v2/sso/workspaces — Lister les workspaces avec SSO actif
|
||||
POST /api/v2/sso/sync — Forcer la synchro des groupes/attributs
|
||||
```
|
||||
|
||||
### 4.2 Service `app/services/sso_provisioning.py`
|
||||
|
||||
```python
|
||||
class SSOService:
|
||||
"""Provisioning et gestion des utilisateurs SSO."""
|
||||
|
||||
def handle_sso_login(self, sso_data: dict, provider_type: str) -> dict:
|
||||
"""Traite un login SSO : trouve/crée l'utilisateur, crée la session."""
|
||||
# 1. Extraire les attributs selon le provider
|
||||
# 2. Chercher l'utilisateur par email/login SSO
|
||||
# 3. Si trouvé → mise à jour des attributs
|
||||
# 4. Si non trouvé et auto_provision → création
|
||||
# 5. Assigner le workspace par défaut ou le premier disponible
|
||||
# 6. Vérifier les groupes SSO → rôle workspace
|
||||
# 7. Créer la session
|
||||
|
||||
def sync_sso_groups(self, user_id: int, sso_groups: list[str]) -> None:
|
||||
"""Sync les groupes SSO vers les rôles workspace."""
|
||||
# Comparer sso_groups avec groups_mapping
|
||||
# Mettre à jour workspace_members.role
|
||||
|
||||
def enforce_sso_restriction(self, workspace_id: int) -> bool:
|
||||
"""Vérifie si le workspace est en mode SSO-only."""
|
||||
# Si config.sso_only = True → interdire le login local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité
|
||||
|
||||
### 5.1 Validation des assertions SAML
|
||||
|
||||
| Validation | Détail |
|
||||
|------------|--------|
|
||||
| Signature | Vérifier la signature avec le certificat IdP |
|
||||
| `NotBefore` / `NotOnOrAfter` | Rejeter les assertions expirées |
|
||||
| `Audience` | Vérifier que l'audience match l'entity_id du SP |
|
||||
| `Destination` | Vérifier que le destination match notre callback URL |
|
||||
| `Issuer` | Vérifier que l'émetteur est le IdP attendu |
|
||||
| `InResponseTo` | Prévenir le replay attack |
|
||||
|
||||
### 5.2 Protection supplémentaire
|
||||
|
||||
- **CSRF sur le callback** : le `RelayState` contient un token CSRF validé
|
||||
- **Rate limiting** sur les endpoints SSO : max 5 tentatives/minute
|
||||
- **Logging audit** : chaque login SSO est enregistré (user, provider, IP, timestamp)
|
||||
- **Session fixation** : nouvelle session créée après chaque SSO login
|
||||
- **Encryption** : `client_secret` chiffré avec `app_secret_key` avant stockage
|
||||
|
||||
### 5.3 Table `sso_login_history`
|
||||
|
||||
```sql
|
||||
CREATE TABLE sso_login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
sso_identifier TEXT, -- email ou subject du SSO
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sso_history_user ON sso_login_history(user_id, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface utilisateur
|
||||
|
||||
### 6.1 Login page — ajout SSO buttons
|
||||
|
||||
```html
|
||||
<!-- Extension de login.html — boutons SSO -->
|
||||
<div class="sso-providers" x-show="ssoProviders.length > 0">
|
||||
<div class="sso-divider">ou continuez avec</div>
|
||||
<template x-for="provider in ssoProviders">
|
||||
<button @click="ssoLogin(provider.type)" class="sso-btn">
|
||||
<span x-text="provider.icon"></span>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6.2 Settings — Configuration SSO (admin)
|
||||
|
||||
Nouvelle section dans `settings.html` → onglet **"SSO / Enterprise"** :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ SSO / Enterprise Authentication │
|
||||
│───────────────────────────────────────────────────────│
|
||||
│ Provider: [▼ SAML / OIDC] │
|
||||
│ │
|
||||
│ SAML Configuration: │
|
||||
│ ├── Entity ID: [___________________________] │
|
||||
│ ├── SSO URL: [___________________________] │
|
||||
│ ├── SLO URL: [___________________________] │
|
||||
│ ├── Certificate: [text area - PEM] │
|
||||
│ └── Attribute Mapping: [JSON editor] │
|
||||
│ │
|
||||
│ OIDC Configuration: │
|
||||
│ ├── Issuer URL: [_________________________] │
|
||||
│ ├── Client ID: [_________________________] │
|
||||
│ ├── Client Secret: [_______________________] │
|
||||
│ └── Scope: [openid profile email] │
|
||||
│ │
|
||||
│ User Provisioning: │
|
||||
│ ├── Auto-provision new users [✓] │
|
||||
│ ├── Default workspace: [dropdown] │
|
||||
│ └── Group mapping: [table editor] │
|
||||
│ │
|
||||
│ ┌─ Group Mappings ───────────────────────────────────┐ │
|
||||
│ │ SSO Group │ FlowDeck Role │ Workspace │ │
|
||||
│ │ FlowDeck Admins │ admin │ Main │ │
|
||||
│ │ FlowDeck Members │ editor │ Main │ │
|
||||
│ └────────────────────┴────────────────┴──────────────┘ │
|
||||
│ │
|
||||
│ [Save SSO Configuration] [Disable SSO] │
|
||||
│ Status: ✅ Active — 12 users provisioned via SSO │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 Métadonnées SP pour l'IdP
|
||||
|
||||
Endpoint `GET /auth/saml/metadata` retourne le XML SAML metadata :
|
||||
|
||||
```xml
|
||||
<EntityDescriptor entityID="https://flowdeck.local/saml/metadata">
|
||||
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<KeyDescriptor use="signing">
|
||||
<KeyInfo>...</KeyInfo>
|
||||
</KeyDescriptor>
|
||||
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="https://flowdeck.local/auth/saml/callback" />
|
||||
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
Location="https://flowdeck.local/auth/saml/logout" />
|
||||
</SPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Intégration avec le système existant
|
||||
|
||||
### 7.1 Comportement selon le type de compte
|
||||
|
||||
```
|
||||
SSO ACTIVÉ + local auth ENABLED:
|
||||
→ Boutons SSO visibles sur la login page
|
||||
→ Login local disponible (optionnel, admin-toggle)
|
||||
→ Les utilisateurs SSO et locaux coexistent
|
||||
|
||||
SSO ACTIVÉ + local auth DISABLED:
|
||||
→ Seuls les boutons SSO sont affichés
|
||||
→ Login local caché / 403 pour /auth/local-login
|
||||
→ Admin du workspace peut garder le sien
|
||||
|
||||
SSO ACTIVÉ + compte local existant:
|
||||
→ Email match → fusion automatique des sessions
|
||||
→ Email ne match pas → login local séparé (deux comptes)
|
||||
```
|
||||
|
||||
### 7.2 Interaction avec le PermissionManager
|
||||
|
||||
Le `PermissionManager` existant (`app/services/permission_manager.py`) est étendu :
|
||||
|
||||
```python
|
||||
class PermissionManager:
|
||||
# Méthodes existantes conservées
|
||||
# + Nouvelles méthodes SSO :
|
||||
|
||||
def is_sso_only_workspace(self, workspace_id: int) -> bool:
|
||||
"""Vérifie si le workspace exige SSO."""
|
||||
|
||||
def get_sso_roles(self, user_id: int, workspace_id: int) -> list[str]:
|
||||
"""Retourne les rôles issus des groupes SSO."""
|
||||
|
||||
def sync_sso_permissions(self, user_id: int, sso_groups: list[str], workspace_id: int):
|
||||
"""Met à jour les rôles basés sur les groupes SSO."""
|
||||
```
|
||||
|
||||
### 7.3 Cookie de session SSO
|
||||
|
||||
Le cookie `flowdeck_session` existant est utilisé tel quel. Une colonne supplémentaire dans la table `users` distingue les utilisateurs SSO :
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local';
|
||||
-- Valeurs: 'local', 'gitea', 'github', 'saml', 'oidc'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
| Test | Description | Outil |
|
||||
|------|-------------|-------|
|
||||
| SAML login flow | Redirection complète IdP → callback → session | Playwright + mock IdP |
|
||||
| SAML metadata | Endpoint retourne XML valide | Unit test |
|
||||
| SAML assertion validation | Signature invalide → rejet | Unit test |
|
||||
| SAML replay attack | Même assertion utilisée 2× → rejet | Unit test |
|
||||
| OIDC login flow | Authorization code → token → session | Playwright + mock OIDC |
|
||||
| Auto-provision | Nouveau user SSO → création auto | Unit test |
|
||||
| Group mapping | Groupes SSO → rôles workspace | Unit test |
|
||||
| SSO disable | Désactiver SSO → tous les users restants gardent accès | Unit test |
|
||||
| SLO | Logout → redirection IdP SLO | Playwright |
|
||||
| Mixed auth | Login SSO + login local dans le même workspace | Integration test |
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist d'implémentation
|
||||
|
||||
1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2
|
||||
2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx)
|
||||
3. **`auth/routers/sso.py`** — endpoints SSO/OIDC
|
||||
4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users`
|
||||
5. **`services/sso_provisioning.py`** — auto-provision + group mapping
|
||||
6. **Extension `settings.html`** — UI admin SSO
|
||||
7. **Extension `login.html`** — boutons SSO
|
||||
8. **SP metadata endpoint** (`/auth/saml/metadata`)
|
||||
9. **Security** — validation assertions, rate limiting, audit log
|
||||
10. **Tests** — tous les scénarios SSO
|
||||
11. **Documentation utilisateur** — `/help` section SSO setup
|
||||
12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt
|
||||
|
||||
---
|
||||
|
||||
## 10. Dépendances Python
|
||||
|
||||
```
|
||||
# requirements.txt additions pour v6.0.0 SSO
|
||||
python3-saml>=1.16.0 # SAML 2.0 SP
|
||||
authlib>=1.3.0 # OIDC client
|
||||
cryptography>=42.0 # Signature/encryption
|
||||
xmlsec>=1.3.0 # XML signature validation (optionnel)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Références
|
||||
|
||||
- [OASIS SAML 2.0 Core Specification](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf)
|
||||
- [SAML 2.0 for Dummies (Simplified)](https://www.onelogin.com/sites/default/files/resources/SAML_2.0_for_Dummies.pdf)
|
||||
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [Azure AD SAML integration](https://learn.microsoft.com/en-us/azure/active-directory/hybrid/how-to-connect-saml-idp)
|
||||
- [Authlib — OIDC client](https://docs.authlib.org/en/latest/client/oidc.html)
|
||||
- `app/auth/providers.py` — Architecture provider existante
|
||||
- `app/services/permission_manager.py` — Extension des rôles
|
||||
- `docs/API_GUIDE_V6.md` — Référence API v2
|
||||
- `ROADMAP.md` — v6.0.0 SSO/SAML item
|
||||
@@ -0,0 +1,590 @@
|
||||
# V6.2.0 — Web Clipper : Extension Navigateur
|
||||
|
||||
> **Statut** : COMPLETED — livré v6.2.0 (2026-09-19) + polish v6.2.1 (2026-09-20) sur `main` (`0b25164`, `ea19d1d`)
|
||||
> **Date** : 2026-09-15 conception → 2026-09-19 implémentation
|
||||
> **Route** : `feat/v6-web-clipper` → `develop` → `main` ✅ mergé
|
||||
> **Dépendances** : v4.0.0 Share & Publish, v5.5.0 Embeds & Rich Media, v5.2.0 OAuth (GitHub)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & objectifs
|
||||
|
||||
Créer une **extension de navigateur** qui permet aux utilisateurs de capturer du contenu web (articles, pages, images, bookmarks) directement dans FlowDeck. Le Web Clipper agit comme un pont entre le web et FlowDeck, transformant n'importe quelle page web en une page FlowDeck.
|
||||
|
||||
### Objectifs
|
||||
|
||||
| Critère | Cible |
|
||||
|---------|-------|
|
||||
| Navigateurs supportés | Chrome, Firefox, Edge, Safari |
|
||||
| Types de capture | Article complet, sélection, bookmark, screenshot |
|
||||
| Temps de capture | < 2 s (page simple), < 5 s (page complexe) |
|
||||
| Authentification | OAuth via popup (sans mot de passe) |
|
||||
| Format d'import | Markdown + liens + images inline |
|
||||
| Compatibilité | Fonctionne même si FlowDeck est fermé |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### 2.1 Composants
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NAVIGATEUR │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ EXTENSION (Manifest V3) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ Content │ │ Popup UI │ │ Background │ │ │
|
||||
│ │ │ Script │ │ (sidebar │ │ Script │ │ │
|
||||
│ │ │ (clipping) │ │ panel) │ │ (OAuth + sync) │ │ │
|
||||
│ │ └──────┬──────┘ └──────┬──────┘ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └────────┬───────┘ │ │ │
|
||||
│ │ ▼ │ │ │
|
||||
│ │ ┌────────────────┐ │ │ │
|
||||
│ │ │ Content │ │ │ │
|
||||
│ │ │ Extractor │ │ │ │
|
||||
│ │ │ (Readability) │ │ │ │
|
||||
│ │ └────────┬───────┘ │ │ │
|
||||
│ └─────────────────┼──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ POST /api/v2/web-clipper/clip │ │
|
||||
└────────────────────┼─────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────▼─────────────────────────────────────────┐
|
||||
│ FASTAPI (Server) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ routers/web_clipper.py │ │
|
||||
│ │ ├─ POST /api/v2/web-clipper/clip — recevoir la clip │ │
|
||||
│ │ ├─ GET /api/v2/web-clipper/status — stat OAuth │ │
|
||||
│ │ └─ POST /api/v2/web-clipper/auth/verify — vérifier │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ services/web_clipper.py │ │
|
||||
│ │ ├─ extract_article() — Readability.js / Mercury Parser │ │
|
||||
│ │ ├─ extract_selection() — sélection HTML → Markdown │ │
|
||||
│ │ ├─ create_page_from_clip() — créer la page FlowDeck │ │
|
||||
│ │ ├─ download_images() — télécharger les images inline │ │
|
||||
│ │ └─ generate_thumbnail() — preview image │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Manifest V3 de l'extension
|
||||
|
||||
### 3.1 `manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FlowDeck Web Clipper",
|
||||
"description": "Capturez du contenu web directement dans FlowDeck",
|
||||
"version": "1.0.0",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"scripting",
|
||||
"contextMenus"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>",
|
||||
"https://flowdeck.local/*",
|
||||
"https://flowdeck.dracodev.net/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["clipper.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["reader-mode.js", "readability.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints serveur
|
||||
|
||||
### 4.1 Routeur `app/routers/web_clipper.py`
|
||||
|
||||
```
|
||||
POST /api/v2/web-clipper/clip — Recevoir une capture et créer une page
|
||||
GET /api/v2/web-clipper/status — Vérifier l'authentification de l'extension
|
||||
POST /api/v2/web-clipper/auth/verify — Vérifier le token OAuth de l'extension
|
||||
POST /api/v2/web-clipper/auth/callback — OAuth callback pour l'extension
|
||||
```
|
||||
|
||||
### 4.2 Service `app/services/web_clipper.py`
|
||||
|
||||
```python
|
||||
class WebClipperService:
|
||||
"""Traitement des captures web."""
|
||||
|
||||
async def process_clip(self, clip_data: ClipPayload, user_id: int) -> dict:
|
||||
"""Traite une capture complète et crée la page FlowDeck.
|
||||
|
||||
ClipPayload:
|
||||
- url: str (URL source)
|
||||
- title: str (titre extrait)
|
||||
- content: str (HTML ou Markdown)
|
||||
- content_type: str ('article' | 'selection' | 'bookmark' | 'screenshot')
|
||||
- images: list[dict] (URLs + base64 data)
|
||||
- metadata: dict (og:title, og:description, author, date_published)
|
||||
- workspace_id: int | None
|
||||
- collection_id: int | None
|
||||
- parent_page_id: int | None
|
||||
"""
|
||||
|
||||
async def extract_article(self, html: str, url: str) -> dict:
|
||||
"""Extrait le contenu principal d'un article via Readability.js."""
|
||||
# Retourne: {title, content (HTML), text_content (Markdown), images[]}
|
||||
|
||||
async def extract_selection(self, html: str, selection_html: str) -> dict:
|
||||
"""Extrait la sélection de texte de la page."""
|
||||
# Retourne: {content: HTML, text_content: Markdown}
|
||||
|
||||
async def create_page_from_clip(self, clip_data: dict, user_id: int) -> dict:
|
||||
"""Crée une page FlowDeck à partir d'une capture.
|
||||
Inclut la création d'images inline (upload vers /api/upload)."""
|
||||
|
||||
async def download_images(self, image_urls: list[str]) -> list[str]:
|
||||
"""Télécharge les images et retourne les URLs locales."""
|
||||
```
|
||||
|
||||
### 4.3 Payload de la capture
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"title": "Titre de l'article",
|
||||
"content": "<article>...contenu HTML...</article>",
|
||||
"content_type": "article",
|
||||
"content_format": "html",
|
||||
"images": [
|
||||
{"src": "https://example.com/image1.jpg", "alt": "Description", "base64": null},
|
||||
{"src": "https://example.com/image2.png", "alt": null, "base64": "data:image/png;base64,..."}
|
||||
],
|
||||
"metadata": {
|
||||
"og_title": "Titre Open Graph",
|
||||
"og_description": "Description de l'article",
|
||||
"author": "Auteur",
|
||||
"date_published": "2026-09-01",
|
||||
"site_name": "Example.com",
|
||||
"favicon_url": "https://example.com/favicon.ico"
|
||||
},
|
||||
"target_workspace_id": 42,
|
||||
"target_collection_id": null,
|
||||
"target_page_id": null,
|
||||
"tags": ["lecture", "important"],
|
||||
"create_as_draft": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Contenu de l'extension côté client
|
||||
|
||||
### 5.1 Content Script — Extraction du contenu
|
||||
|
||||
```javascript
|
||||
// content.js — injecté dans toutes les pages
|
||||
|
||||
class FlowDeckClipper {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 1. Bouton flottant "Clip to FlowDeck"
|
||||
this.createFloatButton();
|
||||
|
||||
// 2. Menu contextuel (clic droit)
|
||||
this.createContextMenu();
|
||||
|
||||
// 3. Keyboard shortcut (Ctrl+Shift+C)
|
||||
this.registerShortcut();
|
||||
}
|
||||
|
||||
createFloatButton() {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'fd-clipper-btn';
|
||||
btn.innerHTML = '📌 Clip to FlowDeck';
|
||||
btn.addEventListener('click', () => this.openClipper());
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
async openClipper() {
|
||||
const url = window.location.href;
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selection = window.getSelection().toString();
|
||||
|
||||
// Envoyer au service worker
|
||||
const clipData = { url, html, selection, title: document.title };
|
||||
await chrome.runtime.sendMessage({ action: 'clip', data: clipData });
|
||||
}
|
||||
|
||||
createContextMenu() {
|
||||
// Cliquez sur une sélection → menu contextuel "Send to FlowDeck"
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const selection = window.getSelection().toString();
|
||||
if (selection.length > 0) {
|
||||
const menuItem = document.createElement('div');
|
||||
menuItem.className = 'fd-context-menu';
|
||||
menuItem.innerHTML = '<span>📌 Clip selection to FlowDeck</span>';
|
||||
menuItem.addEventListener('click', () => this.clipSelection(selection));
|
||||
document.body.appendChild(menuItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Content Extractor — Readability.js
|
||||
|
||||
L'extension embarque une version simplifiée de **Readability.js** (Mozilla) pour extraire le contenu principal des articles :
|
||||
|
||||
```javascript
|
||||
// reader-mode.js — extraction du contenu principal
|
||||
// Version embarquée simplifiée basée sur l'algorithme Readability
|
||||
|
||||
function extractArticle(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
// Algorithme : trouver le meilleur candidat basé sur la longueur de texte,
|
||||
// les balises <article>, <main>, <div role="article">, etc.
|
||||
// Retourne: { title, content, textContent }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Background Script — OAuth et Sync
|
||||
|
||||
```javascript
|
||||
// background.js — Service worker de l'extension
|
||||
|
||||
// Stockage local des tokens et config
|
||||
const STORAGE_KEY = 'flowdeck_clipper';
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.storage.local.set({ [STORAGE_KEY]: { authenticated: false } });
|
||||
});
|
||||
|
||||
// ── Gestion des messages ──
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === 'clip') {
|
||||
handleClip(message.data).then(result => sendResponse(result));
|
||||
return true; // async
|
||||
}
|
||||
if (message.action === 'auth') {
|
||||
handleAuth().then(result => sendResponse(result));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Clip → Serveur ──
|
||||
async function handleClip(data) {
|
||||
const token = await getToken();
|
||||
if (!token || !isAuthenticated()) {
|
||||
return { error: 'not_authenticated', requiresAuth: true };
|
||||
}
|
||||
|
||||
const response = await fetch('https://flowdeck.local/api/v2/web-clipper/clip', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ── OAuth Flow ──
|
||||
async function handleAuth() {
|
||||
// Popup OAuth → redirect to /auth/sso/callback → receive token
|
||||
// Stocker le token dans chrome.storage.local
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentification de l'extension
|
||||
|
||||
### 6.1 OAuth Device Flow ou Popup
|
||||
|
||||
L'extension utilise le même OAuth2 que le web :
|
||||
|
||||
```
|
||||
Extension → Background Script → Popup Auth
|
||||
1. User clique "Connect"
|
||||
2. Popup opens → GET /auth/login (avec redirect vers extension)
|
||||
3. OAuth callback → token reçu
|
||||
4. Token stocké dans chrome.storage.local (encrypted)
|
||||
5. Tous les clips sont authentifiés automatiquement
|
||||
```
|
||||
|
||||
### 6.2 Token storage
|
||||
|
||||
```python
|
||||
# Côté serveur : table pour les tokens d'extension
|
||||
ALTER TABLE api_tokens ADD COLUMN source TEXT DEFAULT 'web';
|
||||
-- Valeurs: 'web', 'clipper', 'mobile', 'api'
|
||||
-- Permet de révoquer uniquement les tokens d'extension
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE extension_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL, -- ID unique de l'extension installée
|
||||
token_hash TEXT NOT NULL,
|
||||
scopes TEXT DEFAULT 'read,write',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, device_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Types de capture supportés
|
||||
|
||||
### 7.1 Article complet
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ URL: https://example.com/article │
|
||||
│ │
|
||||
│ ┌─── Article Content ────────────┐ │
|
||||
│ │ Title extracted │ │
|
||||
│ │ Author: John Doe │ │
|
||||
│ │ Date: 2026-09-15 │ │
|
||||
│ │ │ │
|
||||
│ │ Main article text... │ │
|
||||
│ │ │ │
|
||||
│ │ ![#image1.jpg] │ │
|
||||
│ │ Caption from article │ │
|
||||
│ │ │ │
|
||||
│ │ ![#image2.png] │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Tags: [lecture, tech] │
|
||||
│ Target: Workspace X → Collection Y │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 Sélection de texte
|
||||
|
||||
```
|
||||
L'utilisateur sélectionne du texte sur une page → clic droit → "Clip to FlowDeck"
|
||||
→ Crée une page FlowDeck avec le texte sélectionné formaté en Markdown
|
||||
→ Ajoute un lien vers la page source en bas
|
||||
```
|
||||
|
||||
### 7.3 Bookmark
|
||||
|
||||
```
|
||||
L'utilisateur clique sur le bouton clipper sur une page sans contenu riche
|
||||
→ Crée un "bookmark card" (comme v5.5.0 bookmark cards)
|
||||
→ URL, titre, favicon, description OG → page FlowDeck
|
||||
```
|
||||
|
||||
### 7.4 Screenshot
|
||||
|
||||
```
|
||||
L'utilisateur sélectionne une zone → capture en screenshot →
|
||||
→ Image uploadée dans FlowDeck → page avec l'image + annotation possible
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Interface utilisateur du serveur
|
||||
|
||||
### 8.1 Extension management dans Settings
|
||||
|
||||
Nouvelle section dans `settings.html` → onglet **"Extensions"** :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Connected Extensions │
|
||||
│──────────────────────────────────────────────────────│
|
||||
│ FlowDeck Web Clipper │
|
||||
│ ├── Device: Chrome — Windows │
|
||||
│ ├── Connected: 2026-09-10 14:30 │
|
||||
│ ├── Clips this month: 12 │
|
||||
│ └── [Revoke Access] │
|
||||
│ │
|
||||
│ FlowDeck Web Clipper │
|
||||
│ ├── Device: Firefox — macOS │
|
||||
│ ├── Connected: 2026-09-08 09:15 │
|
||||
│ ├── Clips this month: 5 │
|
||||
│ └── [Revoke Access] │
|
||||
│ │
|
||||
│ [Download Chrome Extension] [Download Firefox Add-on] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 Page de téléchargement : `/extensions`
|
||||
|
||||
Nouvelle page publique offrant les liens de téléchargement de l'extension :
|
||||
|
||||
```
|
||||
GET /extensions — Page de téléchargement
|
||||
GET /extensions/chrome — Chrome Web Store link / CRX download
|
||||
GET /extensions/firefox — Firefox Add-on link / XPI download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Tables de base de données
|
||||
|
||||
### 9.1 Modifications
|
||||
|
||||
```sql
|
||||
-- Extension tracking
|
||||
CREATE TABLE extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL, -- 'chrome', 'firefox', 'edge', 'safari'
|
||||
device_id TEXT NOT NULL, -- UUID unique par installation
|
||||
device_name TEXT, -- "Chrome — Windows 11"
|
||||
token_hash TEXT NOT NULL,
|
||||
scopes TEXT DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ext_devices_user ON extension_devices(user_id);
|
||||
CREATE INDEX idx_ext_devices_device ON extension_devices(device_id);
|
||||
|
||||
-- Extension activity log
|
||||
CREATE TABLE extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
clip_type TEXT NOT NULL, -- 'article', 'selection', 'bookmark', 'screenshot'
|
||||
source_url TEXT NOT NULL,
|
||||
target_page_id INTEGER REFERENCES collection_pages(id),
|
||||
target_workspace_id INTEGER,
|
||||
title TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_clips_user ON extension_clips(user_id, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Sécurité
|
||||
|
||||
| Menace | Contre-mesure |
|
||||
|--------|---------------|
|
||||
| Extension malveillante | Device ID unique + token par device, pas de partage de tokens |
|
||||
| CSRF via extension | Origin checking dans le server-side validation |
|
||||
| Content injection | Sanitisation HTML côté serveur avant création de page |
|
||||
| Données sensibles | Pas de cookies envoyés à l'extension, uniquement tokens OAuth |
|
||||
| Rate limiting | Max 50 clips/heure par device |
|
||||
| Large payload | Max 10 MB par clip (images incluses) |
|
||||
|
||||
### 10.1 Content Security Policy
|
||||
|
||||
```python
|
||||
# Extension CSP étendu
|
||||
# Ajouter dans le CSP de l'app :
|
||||
# connect-src: ws: https://flowdeck.local https://api.flowdeck.local
|
||||
# Content-Security-Policy header pour les pages d'extension
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Tests
|
||||
|
||||
| Test | Description | Outil |
|
||||
|------|-------------|-------|
|
||||
| Clip article | Capturer un article → créer une page FlowDeck | Playwright + HTTP test |
|
||||
| Clip sélection | Sélectionner du texte → créer une page avec le texte | Playwright |
|
||||
| Clip bookmark | Cliquer sur un bookmark → créer une carte bookmark | Unit test |
|
||||
| Auth flow | Extension OAuth → token → clip | Integration test |
|
||||
| Image download | Article avec images → images téléchargées et inline | Unit test |
|
||||
| Multiple devices | 2 extensions connectées → clips séparés | Integration test |
|
||||
| Revoke | Révoquer un device → clips rejetés | Unit test |
|
||||
| Rate limit | 51 clips → 429 Too Many Requests | Unit test |
|
||||
| Content sanitization | HTML malveillant → page propre créée | Security test |
|
||||
| Readability extraction | Page complexe → contenu principal extrait | Unit test |
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist d'implémentation — ✅ LIVRÉ (v6.2.0/6.2.1)
|
||||
|
||||
1. ✅ **`app/routers/web_clipper.py`** — `POST /clip`, `GET /status`, `POST /auth/verify`, `GET/DELETE /devices` + page `/extensions`
|
||||
2. ✅ **`app/services/web_clipper.py`** — `sanitize_html`, `html_to_blocks`, `extract_article`, `create_page_from_clip`, `register_device`, `log_clip`, rate-limit 50/h
|
||||
3. ✅ **Migrations DB (19)** — `extension_devices`, `extension_clips` (index `idx_ext_*`)
|
||||
4. ✅ **Extraction embarquée** dans le content script (heuristique article/sélection)
|
||||
5. ✅ **Extension côté client** — `extension/` + `static/extension/` : manifest.json, content.js, background.js, popup.html/js, clipper.css, icônes, `flowdeck-clipper.zip`
|
||||
6. ✅ **Settings UI** — section Extensions dans `settings.html`
|
||||
7. ✅ **Page `/extensions`** — téléchargement + liste devices/clips
|
||||
8. ✅ **Auth** — session cookie OU Bearer `api_tokens` / `extension_devices` (token montré une fois)
|
||||
9. ✅ **Content sanitization** — nettoyage HTML côté serveur (`sanitize_html`)
|
||||
10. ✅ **Tests** — `tests/test_web_clipper.py` (16 tests)
|
||||
11. ⚠️ **Packaging** — bundle `.zip` servi ; soumission Chrome Web Store / Firefox Add-on **non faite** (distribution manuelle « load unpacked »)
|
||||
12. ✅ **Documentation** — page `/extensions`, `/help`, ce guide
|
||||
|
||||
---
|
||||
|
||||
## 13. Dépendances Python
|
||||
|
||||
```
|
||||
# requirements.txt additions pour v6.0.0 Web Clipper
|
||||
beautifulsoup4>=4.12 # HTML parsing / extraction
|
||||
lxml>=4.9 # XML/HTML parser rapide
|
||||
html2text>=2024.2.26 # HTML → Markdown conversion
|
||||
requests>=2.32 # Download images dans le service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Références
|
||||
|
||||
- [Chrome Extension Manifest V3](https://developer.chrome.com/docs/extensions/mv3/intro/)
|
||||
- [Firefox Add-on Development](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons)
|
||||
- [Readability.js (Mozilla)](https://github.com/mozilla/readability)
|
||||
- [Mercury Parser](https://github.com/postlight/mercury-parser)
|
||||
- [OAuth 2.0 Device Flow](https://datatracker.ietf.org/doc/html/rfc8628)
|
||||
- `app/services/web_clipper.py` — service de traitement (livré)
|
||||
- `app/routers/web_clipper.py` — endpoints clip/auth (livré)
|
||||
- `app/templates/settings.html` — section Extensions (livrée)
|
||||
- `extension/` + `static/extension/` — bundle navigateur (livré)
|
||||
- `docs/API_GUIDE_V6.md` — référence API v2
|
||||
- `ROADMAP.md` — v6.2.0 Web Clipper (livré)
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,143 @@
|
||||
// FlowDeck Web Clipper — background service worker (Manifest V3)
|
||||
const STORAGE_KEY = "flowdeck_clipper";
|
||||
const DEFAULT_SERVER = "http://localhost:8080";
|
||||
|
||||
async function getConfig() {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
const cfg = data[STORAGE_KEY] || { serverUrl: DEFAULT_SERVER, token: "", deviceId: "" };
|
||||
if (cfg.showButton === undefined) cfg.showButton = true;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function setConfig(cfg) {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
||||
}
|
||||
|
||||
function genDeviceId() {
|
||||
return "dev_" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
async function ensureDeviceId() {
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.deviceId) {
|
||||
cfg.deviceId = genDeviceId();
|
||||
await setConfig(cfg);
|
||||
}
|
||||
return cfg.deviceId;
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
await ensureDeviceId();
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-selection",
|
||||
title: "📌 Clip selection to FlowDeck",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-page",
|
||||
title: "📌 Clip page to FlowDeck",
|
||||
contexts: ["page"]
|
||||
});
|
||||
});
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (!tab || !tab.id) return;
|
||||
if (info.menuItemId === "fd-clip-selection") {
|
||||
chrome.tabs.sendMessage(tab.id, { action: "clipSelection" });
|
||||
} else if (info.menuItemId === "fd-clip-page") {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.__fdOpenClipper && window.__fdOpenClipper("article")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Unified clip handler
|
||||
async function handleClip(data) {
|
||||
const cfg = await getConfig();
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const deviceId = cfg.deviceId || (await ensureDeviceId());
|
||||
const token = cfg.token || "";
|
||||
|
||||
// Require token or let server handle session cookie fallback
|
||||
if (!token) {
|
||||
// Try clipboard without token; server may accept session if user logged in flowdeck tab
|
||||
// but background fetch cannot send cookie cross-origin without credentials; warn
|
||||
}
|
||||
|
||||
const payload = {
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
content: data.html || data.content,
|
||||
content_type: data.content_type || "article",
|
||||
selection_html: data.selection_html || "",
|
||||
device_id: deviceId,
|
||||
metadata: data.metadata || {}
|
||||
};
|
||||
|
||||
const headers = { "Content-Type": "application/json", "X-Device-Id": deviceId };
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/clip", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
return { error: body.detail || body.error || "clip_failed", status: resp.status, ...body };
|
||||
}
|
||||
// Notify open FlowDeck tabs so the sidebar refreshes instantly without manual reload
|
||||
try {
|
||||
notifyFlowDeckTabs(body);
|
||||
} catch (_e) {}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function notifyFlowDeckTabs(clipResult) {
|
||||
try {
|
||||
const cfg = await getConfig();
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
let origin = "";
|
||||
try { origin = new URL(serverUrl).origin; } catch (_e) { origin = serverUrl; }
|
||||
const tabs = await chrome.tabs.query({});
|
||||
for (const t of tabs) {
|
||||
if (!t.id || !t.url) continue;
|
||||
// Only notify tabs that look like FlowDeck (same origin or localhost variants)
|
||||
const isFlowDeck = t.url.startsWith(origin) || t.url.includes("flowdeck") || t.url.includes("localhost");
|
||||
if (!isFlowDeck) continue;
|
||||
chrome.tabs.sendMessage(t.id, { action: "fdRefreshTree", data: clipResult }).catch(() => {});
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
async function handleAuth() {
|
||||
// Token is set via popup; verify it
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.token) return { error: "no_token" };
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/status", {
|
||||
headers: { Authorization: "Bearer " + cfg.token }
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
return body;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "clip") {
|
||||
handleClip(message.data).then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "auth") {
|
||||
handleAuth().then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "getConfig") {
|
||||
getConfig().then((c) => sendResponse(c));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "setConfig") {
|
||||
setConfig(message.config).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
.fd-clipper-btn{
|
||||
position:fixed;bottom:20px;right:20px;z-index:2147483647;
|
||||
width:44px;height:44px;border-radius:50%;
|
||||
background:rgba(35,131,226,0.14);color:#2383E2;border:1px solid rgba(35,131,226,0.22);
|
||||
backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
|
||||
font-size:18px;font-weight:600;cursor:grab;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.18);font-family:system-ui,sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
user-select:none;touch-action:none;
|
||||
transition:background .15s, border-color .15s, transform .1s;
|
||||
padding:0;line-height:1;
|
||||
}
|
||||
.fd-clipper-btn:hover{background:rgba(35,131,226,0.26);border-color:rgba(35,131,226,0.35);color:#1a6fc2}
|
||||
.fd-clipper-btn:active{cursor:grabbing;transform:scale(0.96);background:rgba(35,131,226,0.32)}
|
||||
.fd-clipper-btn.fd-dragging{cursor:grabbing;opacity:.92;transition:none}
|
||||
.fd-clipper-toast{
|
||||
position:fixed;bottom:72px;right:20px;z-index:2147483647;
|
||||
color:#fff;padding:10px 14px;border-radius:8px;font-size:13px;font-family:system-ui,sans-serif;
|
||||
transform:translateY(10px);opacity:0;transition:all .2s;pointer-events:none;
|
||||
}
|
||||
.fd-clipper-toast.show{transform:translateY(0);opacity:1}
|
||||
@@ -0,0 +1,266 @@
|
||||
// FlowDeck Web Clipper — content script
|
||||
// Injected in every page: floating button + context menu + selection handling
|
||||
(function () {
|
||||
if (window.__fdClipperInjected) return;
|
||||
window.__fdClipperInjected = true;
|
||||
|
||||
const BTN_ID = "fd-clipper-btn";
|
||||
const STORAGE_KEY = "flowdeck_clipper";
|
||||
let dragState = null;
|
||||
|
||||
async function getCfg() {
|
||||
try {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
return data[STORAGE_KEY] || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function createFloatButton() {
|
||||
const cfg = await getCfg();
|
||||
if (cfg.showButton === false) return;
|
||||
if (document.getElementById(BTN_ID)) return;
|
||||
if (!document.body) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.id = BTN_ID;
|
||||
btn.className = "fd-clipper-btn";
|
||||
btn.textContent = "📌";
|
||||
btn.title = "Clip to FlowDeck (glisser pour déplacer)";
|
||||
btn.setAttribute("aria-label", "Clip to FlowDeck");
|
||||
// Apply saved position
|
||||
const pos = cfg.pos;
|
||||
if (pos && typeof pos.left === "number" && typeof pos.top === "number") {
|
||||
btn.style.left = pos.left + "px";
|
||||
btn.style.top = pos.top + "px";
|
||||
btn.style.right = "auto";
|
||||
btn.style.bottom = "auto";
|
||||
}
|
||||
btn.addEventListener("click", (e) => {
|
||||
if (btn.dataset.dragged === "1") {
|
||||
btn.dataset.dragged = "0";
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
openClipper("article");
|
||||
});
|
||||
btn.addEventListener("mousedown", onDragStart);
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
function removeFloatButton() {
|
||||
const el = document.getElementById(BTN_ID);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function applyVisibility(show) {
|
||||
if (show === false) {
|
||||
removeFloatButton();
|
||||
} else {
|
||||
const el = document.getElementById(BTN_ID);
|
||||
if (el) el.style.display = "";
|
||||
else createFloatButton();
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
const btn = e.currentTarget;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dragState = {
|
||||
btn: btn,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
moved: false
|
||||
};
|
||||
// Prevent text selection during drag
|
||||
e.preventDefault();
|
||||
document.addEventListener("mousemove", onDragMove);
|
||||
document.addEventListener("mouseup", onDragEnd);
|
||||
btn.classList.add("fd-dragging");
|
||||
}
|
||||
|
||||
function onDragMove(e) {
|
||||
if (!dragState) return;
|
||||
const dx = Math.abs(e.clientX - dragState.startX);
|
||||
const dy = Math.abs(e.clientY - dragState.startY);
|
||||
if (dx > 3 || dy > 3) dragState.moved = true;
|
||||
const btn = dragState.btn;
|
||||
let left = e.clientX - dragState.offsetX;
|
||||
let top = e.clientY - dragState.offsetY;
|
||||
const maxLeft = window.innerWidth - btn.offsetWidth - 4;
|
||||
const maxTop = window.innerHeight - btn.offsetHeight - 4;
|
||||
left = Math.max(4, Math.min(left, maxLeft));
|
||||
top = Math.max(4, Math.min(top, maxTop));
|
||||
btn.style.left = left + "px";
|
||||
btn.style.top = top + "px";
|
||||
btn.style.right = "auto";
|
||||
btn.style.bottom = "auto";
|
||||
}
|
||||
|
||||
async function onDragEnd(e) {
|
||||
document.removeEventListener("mousemove", onDragMove);
|
||||
document.removeEventListener("mouseup", onDragEnd);
|
||||
if (!dragState) return;
|
||||
const btn = dragState.btn;
|
||||
btn.classList.remove("fd-dragging");
|
||||
const moved = dragState.moved;
|
||||
dragState = null;
|
||||
if (moved) {
|
||||
// Suppress next click
|
||||
btn.dataset.dragged = "1";
|
||||
setTimeout(() => { btn.dataset.dragged = "0"; }, 400);
|
||||
const left = parseInt(btn.style.left, 10);
|
||||
const top = parseInt(btn.style.top, 10);
|
||||
if (!isNaN(left) && !isNaN(top)) {
|
||||
try {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
const cfg = data[STORAGE_KEY] || {};
|
||||
cfg.pos = { left: left, top: top };
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
||||
} catch (_e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to storage changes (show/hide toggle & position sync across tabs)
|
||||
try {
|
||||
if (chrome.storage && chrome.storage.onChanged) {
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== "local" || !changes[STORAGE_KEY]) return;
|
||||
const nv = changes[STORAGE_KEY].newValue || {};
|
||||
const ov = changes[STORAGE_KEY].oldValue || {};
|
||||
if (nv.showButton !== ov.showButton) {
|
||||
applyVisibility(nv.showButton !== false);
|
||||
}
|
||||
// Position already handled via direct style; if changed elsewhere sync it
|
||||
if (nv.pos && JSON.stringify(nv.pos) !== JSON.stringify(ov.pos)) {
|
||||
const el = document.getElementById(BTN_ID);
|
||||
if (el && nv.pos.left != null) {
|
||||
el.style.left = nv.pos.left + "px";
|
||||
el.style.top = nv.pos.top + "px";
|
||||
el.style.right = "auto";
|
||||
el.style.bottom = "auto";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
function getSelectionHtml() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.toString().trim().length < 3) return "";
|
||||
const range = sel.getRangeAt(0);
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(range.cloneContents());
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function openClipper(type) {
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = getSelectionHtml();
|
||||
const selectionText = window.getSelection() ? window.getSelection().toString().trim() : "";
|
||||
let clipType = type;
|
||||
if (selectionHtml && selectionText.length > 20) clipType = "selection";
|
||||
const payload = {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
content: html,
|
||||
content_type: clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_title: document.querySelector('meta[property="og:title"]')?.content || "",
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || "",
|
||||
author: document.querySelector('meta[name="author"]')?.content || ""
|
||||
}
|
||||
};
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: payload });
|
||||
if (resp && resp.error === "not_authenticated") {
|
||||
alert("FlowDeck: not authenticated. Open FlowDeck settings to connect the clipper.");
|
||||
} else if (resp && resp.status === "ok") {
|
||||
showToast("Clipped to FlowDeck \u2713 \u2014 page " + resp.page_id);
|
||||
} else if (resp && resp.error) {
|
||||
showToast("Clip failed: " + resp.error, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("FlowDeck clipper", e);
|
||||
showToast("Clip failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
let t = document.getElementById("fd-clipper-toast");
|
||||
if (!t) {
|
||||
t = document.createElement("div");
|
||||
t.id = "fd-clipper-toast";
|
||||
t.className = "fd-clipper-toast";
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.background = isError ? "#e03e3e" : "#1a7f37";
|
||||
t.classList.add("show");
|
||||
setTimeout(() => t.classList.remove("show"), 3000);
|
||||
}
|
||||
|
||||
// Runtime messages: from background (context menu, refresh) or popup
|
||||
try {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.action) return;
|
||||
if (message.action === "clipSelection") {
|
||||
openClipper("selection");
|
||||
try { sendResponse({ ok: true }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
if (message.action === "fdRefreshTree" || message.action === "refreshTree" || message.action === "fdClipCreated") {
|
||||
// FlowDeck page refresh signal (injected on FlowDeck origin)
|
||||
try {
|
||||
if (window.appState && typeof window.appState.refreshSidebarTree === "function") {
|
||||
window.appState.refreshSidebarTree();
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent("flowdeck:clip-created", { detail: message.data || {} }));
|
||||
document.dispatchEvent(new CustomEvent("flowdeck:workspace-changed"));
|
||||
// Fallback: try calling global
|
||||
if (window.FlowDeck && window.FlowDeck._toast) {}
|
||||
}
|
||||
// Also try direct fetch fallback
|
||||
const ul = document.getElementById("sidebar-workspace-items");
|
||||
if (ul && window.appState && window.appState.refreshSidebarTree) {
|
||||
window.appState.refreshSidebarTree();
|
||||
}
|
||||
} catch (_e) {}
|
||||
try { sendResponse({ ok: true }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
if (message.action === "toggleButton") {
|
||||
applyVisibility(message.show);
|
||||
try { sendResponse({ ok: true }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
if (message.action === "getSelectionHtml") {
|
||||
try { sendResponse({ html: getSelectionHtml() }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} catch (_e) {}
|
||||
|
||||
// Keyboard shortcut Ctrl+Shift+C
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
openClipper("article");
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for popup / background scripting
|
||||
window.__fdOpenClipper = openClipper;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createFloatButton);
|
||||
} else {
|
||||
createFloatButton();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FlowDeck Web Clipper",
|
||||
"description": "Capturez du contenu web directement dans FlowDeck",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["activeTab", "storage", "scripting", "contextMenus"],
|
||||
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["clipper.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["readability.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;width:340px;padding:16px;background:#191919;color:#e0e0e0}
|
||||
h2{font-size:15px;margin-bottom:6px}
|
||||
.desc{font-size:12px;color:#999;margin-bottom:12px;line-height:1.4}
|
||||
label{font-size:11px;color:#999;text-transform:uppercase;letter-spacing:.5px;display:block;margin-top:10px;margin-bottom:4px}
|
||||
input{width:100%;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:6px;color:#fff;padding:8px 10px;font-size:13px;outline:none}
|
||||
input:focus{border-color:#2383E2}
|
||||
.btn{width:100%;margin-top:12px;padding:9px;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.btn-primary{background:#2383E2;color:#fff}
|
||||
.btn-secondary{background:#2a2a2a;color:#ccc;border:1px solid #3a3a3a}
|
||||
.btn:active{opacity:.9}
|
||||
.row{display:flex;gap:8px;margin-top:10px}
|
||||
.row .btn{flex:1}
|
||||
.status{margin-top:10px;padding:8px 10px;border-radius:6px;font-size:12px;display:none}
|
||||
.status.ok{background:rgba(46,160,67,.15);color:#2ea043;display:block}
|
||||
.status.err{background:rgba(224,62,62,.15);color:#e03e3e;display:block}
|
||||
.status.warn{background:rgba(217,115,13,.15);color:#d9730d;display:block}
|
||||
.small{font-size:11px;color:#777;margin-top:6px}
|
||||
a{color:#2383E2;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>📌 FlowDeck Clipper</h2>
|
||||
<p class="desc">Capture any page into FlowDeck in one click.</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" id="clipArticle">Clip article</button>
|
||||
<button class="btn btn-secondary" id="clipBookmark">Bookmark</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="clipSelection" style="margin-top:8px;">Clip selection</button>
|
||||
|
||||
<label>FlowDeck server URL</label>
|
||||
<input id="serverUrl" placeholder="http://localhost:8080">
|
||||
|
||||
<label>Bearer token <span style="text-transform:none;color:#666">(Settings → API tokens)</span></label>
|
||||
<input id="token" placeholder="fd_... or fd_clip_..." type="password">
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button class="btn btn-secondary" id="save" style="flex:1;padding:7px;font-size:12px;">Save</button>
|
||||
<button class="btn btn-secondary" id="verify" style="flex:1;padding:7px;font-size:12px;">Verify</button>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer;text-transform:none;letter-spacing:0;font-size:13px;color:#e0e0e0"><input type="checkbox" id="showButton" style="width:auto;accent-color:#2383E2"> Afficher le bouton flottant sur les pages</label>
|
||||
<p class="small" style="margin-left:22px">Rond, semi-transparent, déplaçable à la souris.</p>
|
||||
<div id="status" class="status"></div>
|
||||
<p class="small">Need a token? Open <a href="#" id="openFlowDeck">FlowDeck → Settings → API tokens</a> or <a href="#" id="openExtensions">Extensions</a>.</p>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
// Popup logic
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function load() {
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
$("serverUrl").value = cfg.serverUrl || "http://localhost:8080";
|
||||
$("token").value = cfg.token || "";
|
||||
const sb = $("showButton");
|
||||
if (sb) sb.checked = cfg.showButton !== false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const serverUrl = $("serverUrl").value.trim() || "http://localhost:8080";
|
||||
const token = $("token").value.trim();
|
||||
const showButton = $("showButton") ? $("showButton").checked : true;
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
cfg.serverUrl = serverUrl.replace(/\/$/, "");
|
||||
cfg.token = token;
|
||||
cfg.showButton = showButton;
|
||||
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
|
||||
show("Saved ✓", "ok");
|
||||
}
|
||||
|
||||
async function toggleButton(show) {
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
cfg.showButton = show;
|
||||
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
|
||||
// Notify all tabs immediately for instant UX
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
for (const t of tabs) {
|
||||
if (!t.id) continue;
|
||||
chrome.tabs.sendMessage(t.id, { action: "toggleButton", show }).catch(() => {});
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await save();
|
||||
const res = await chrome.runtime.sendMessage({ action: "auth" });
|
||||
if (res && res.authenticated) show(`Connected as ${res.user?.login || "user"} — ${res.devices} device(s)`, "ok");
|
||||
else if (res && res.authenticated === false) show("Not authenticated — check token / server URL", "err");
|
||||
else show(JSON.stringify(res).slice(0, 200), "warn");
|
||||
}
|
||||
|
||||
function show(msg, kind) {
|
||||
const el = $("status");
|
||||
el.textContent = msg;
|
||||
el.className = "status " + kind;
|
||||
}
|
||||
|
||||
async function doClip(type) {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) return;
|
||||
// Get page data via scripting
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (clipType) => {
|
||||
const sel = window.getSelection();
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = (() => {
|
||||
if (!sel || sel.rangeCount === 0) return "";
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
return d.innerHTML;
|
||||
})();
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
html: html,
|
||||
content_type: sel && sel.toString().trim().length > 20 ? "selection" : clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || ""
|
||||
}
|
||||
};
|
||||
},
|
||||
args: [type]
|
||||
});
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: result });
|
||||
if (resp && resp.status === "ok") show(`Clipped → page ${resp.page_id} ✓`, "ok");
|
||||
else show(resp.detail || resp.error || "Clip failed", "err");
|
||||
}
|
||||
|
||||
$("save").addEventListener("click", save);
|
||||
$("verify").addEventListener("click", verify);
|
||||
$("clipArticle").addEventListener("click", () => doClip("article"));
|
||||
$("clipBookmark").addEventListener("click", () => doClip("bookmark"));
|
||||
$("clipSelection").addEventListener("click", () => doClip("selection"));
|
||||
$("openFlowDeck").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: $("serverUrl").value || "http://localhost:8080" }); });
|
||||
$("openExtensions").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: ($("serverUrl").value || "http://localhost:8080") + "/extensions" }); });
|
||||
const _sb = $("showButton");
|
||||
if (_sb) _sb.addEventListener("change", (e) => toggleButton(e.target.checked));
|
||||
|
||||
load();
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"enabled": true
|
||||
},
|
||||
"gh_grep": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.grep.app",
|
||||
"enabled": true
|
||||
},
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp@latest"],
|
||||
"enabled": true
|
||||
},
|
||||
"sqlite": {
|
||||
"type": "local",
|
||||
"command": ["uvx", "--with", "mcp==1.12.0", "mcp-server-sqlite", "--db-path", "C:/dev/git/web/flowdeck/flowdeck_dev.db"],
|
||||
"enabled": true
|
||||
},
|
||||
"fetch": {
|
||||
"type": "local",
|
||||
"command": ["uvx", "mcp-server-fetch"],
|
||||
"enabled": true
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
# Install (uv): uv pip install -r requirements.txt -r requirements-dev.txt --python .venv
|
||||
-r requirements.txt
|
||||
pytest-xdist>=3.6
|
||||
pytest-asyncio>=1.0
|
||||
ruff>=0.9
|
||||
@@ -0,0 +1,143 @@
|
||||
// FlowDeck Web Clipper — background service worker (Manifest V3)
|
||||
const STORAGE_KEY = "flowdeck_clipper";
|
||||
const DEFAULT_SERVER = "http://localhost:8080";
|
||||
|
||||
async function getConfig() {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
const cfg = data[STORAGE_KEY] || { serverUrl: DEFAULT_SERVER, token: "", deviceId: "" };
|
||||
if (cfg.showButton === undefined) cfg.showButton = true;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function setConfig(cfg) {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
||||
}
|
||||
|
||||
function genDeviceId() {
|
||||
return "dev_" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
async function ensureDeviceId() {
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.deviceId) {
|
||||
cfg.deviceId = genDeviceId();
|
||||
await setConfig(cfg);
|
||||
}
|
||||
return cfg.deviceId;
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
await ensureDeviceId();
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-selection",
|
||||
title: "📌 Clip selection to FlowDeck",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-page",
|
||||
title: "📌 Clip page to FlowDeck",
|
||||
contexts: ["page"]
|
||||
});
|
||||
});
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (!tab || !tab.id) return;
|
||||
if (info.menuItemId === "fd-clip-selection") {
|
||||
chrome.tabs.sendMessage(tab.id, { action: "clipSelection" });
|
||||
} else if (info.menuItemId === "fd-clip-page") {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.__fdOpenClipper && window.__fdOpenClipper("article")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Unified clip handler
|
||||
async function handleClip(data) {
|
||||
const cfg = await getConfig();
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const deviceId = cfg.deviceId || (await ensureDeviceId());
|
||||
const token = cfg.token || "";
|
||||
|
||||
// Require token or let server handle session cookie fallback
|
||||
if (!token) {
|
||||
// Try clipboard without token; server may accept session if user logged in flowdeck tab
|
||||
// but background fetch cannot send cookie cross-origin without credentials; warn
|
||||
}
|
||||
|
||||
const payload = {
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
content: data.html || data.content,
|
||||
content_type: data.content_type || "article",
|
||||
selection_html: data.selection_html || "",
|
||||
device_id: deviceId,
|
||||
metadata: data.metadata || {}
|
||||
};
|
||||
|
||||
const headers = { "Content-Type": "application/json", "X-Device-Id": deviceId };
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/clip", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
return { error: body.detail || body.error || "clip_failed", status: resp.status, ...body };
|
||||
}
|
||||
// Notify open FlowDeck tabs so the sidebar refreshes instantly without manual reload
|
||||
try {
|
||||
notifyFlowDeckTabs(body);
|
||||
} catch (_e) {}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function notifyFlowDeckTabs(clipResult) {
|
||||
try {
|
||||
const cfg = await getConfig();
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
let origin = "";
|
||||
try { origin = new URL(serverUrl).origin; } catch (_e) { origin = serverUrl; }
|
||||
const tabs = await chrome.tabs.query({});
|
||||
for (const t of tabs) {
|
||||
if (!t.id || !t.url) continue;
|
||||
// Only notify tabs that look like FlowDeck (same origin or localhost variants)
|
||||
const isFlowDeck = t.url.startsWith(origin) || t.url.includes("flowdeck") || t.url.includes("localhost");
|
||||
if (!isFlowDeck) continue;
|
||||
chrome.tabs.sendMessage(t.id, { action: "fdRefreshTree", data: clipResult }).catch(() => {});
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
async function handleAuth() {
|
||||
// Token is set via popup; verify it
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.token) return { error: "no_token" };
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/status", {
|
||||
headers: { Authorization: "Bearer " + cfg.token }
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
return body;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "clip") {
|
||||
handleClip(message.data).then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "auth") {
|
||||
handleAuth().then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "getConfig") {
|
||||
getConfig().then((c) => sendResponse(c));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "setConfig") {
|
||||
setConfig(message.config).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
.fd-clipper-btn{
|
||||
position:fixed;bottom:20px;right:20px;z-index:2147483647;
|
||||
width:44px;height:44px;border-radius:50%;
|
||||
background:rgba(35,131,226,0.14);color:#2383E2;border:1px solid rgba(35,131,226,0.22);
|
||||
backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
|
||||
font-size:18px;font-weight:600;cursor:grab;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.18);font-family:system-ui,sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
user-select:none;touch-action:none;
|
||||
transition:background .15s, border-color .15s, transform .1s;
|
||||
padding:0;line-height:1;
|
||||
}
|
||||
.fd-clipper-btn:hover{background:rgba(35,131,226,0.26);border-color:rgba(35,131,226,0.35);color:#1a6fc2}
|
||||
.fd-clipper-btn:active{cursor:grabbing;transform:scale(0.96);background:rgba(35,131,226,0.32)}
|
||||
.fd-clipper-btn.fd-dragging{cursor:grabbing;opacity:.92;transition:none}
|
||||
.fd-clipper-toast{
|
||||
position:fixed;bottom:72px;right:20px;z-index:2147483647;
|
||||
color:#fff;padding:10px 14px;border-radius:8px;font-size:13px;font-family:system-ui,sans-serif;
|
||||
transform:translateY(10px);opacity:0;transition:all .2s;pointer-events:none;
|
||||
}
|
||||
.fd-clipper-toast.show{transform:translateY(0);opacity:1}
|
||||
@@ -0,0 +1,266 @@
|
||||
// FlowDeck Web Clipper — content script
|
||||
// Injected in every page: floating button + context menu + selection handling
|
||||
(function () {
|
||||
if (window.__fdClipperInjected) return;
|
||||
window.__fdClipperInjected = true;
|
||||
|
||||
const BTN_ID = "fd-clipper-btn";
|
||||
const STORAGE_KEY = "flowdeck_clipper";
|
||||
let dragState = null;
|
||||
|
||||
async function getCfg() {
|
||||
try {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
return data[STORAGE_KEY] || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function createFloatButton() {
|
||||
const cfg = await getCfg();
|
||||
if (cfg.showButton === false) return;
|
||||
if (document.getElementById(BTN_ID)) return;
|
||||
if (!document.body) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.id = BTN_ID;
|
||||
btn.className = "fd-clipper-btn";
|
||||
btn.textContent = "📌";
|
||||
btn.title = "Clip to FlowDeck (glisser pour déplacer)";
|
||||
btn.setAttribute("aria-label", "Clip to FlowDeck");
|
||||
// Apply saved position
|
||||
const pos = cfg.pos;
|
||||
if (pos && typeof pos.left === "number" && typeof pos.top === "number") {
|
||||
btn.style.left = pos.left + "px";
|
||||
btn.style.top = pos.top + "px";
|
||||
btn.style.right = "auto";
|
||||
btn.style.bottom = "auto";
|
||||
}
|
||||
btn.addEventListener("click", (e) => {
|
||||
if (btn.dataset.dragged === "1") {
|
||||
btn.dataset.dragged = "0";
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
openClipper("article");
|
||||
});
|
||||
btn.addEventListener("mousedown", onDragStart);
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
function removeFloatButton() {
|
||||
const el = document.getElementById(BTN_ID);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function applyVisibility(show) {
|
||||
if (show === false) {
|
||||
removeFloatButton();
|
||||
} else {
|
||||
const el = document.getElementById(BTN_ID);
|
||||
if (el) el.style.display = "";
|
||||
else createFloatButton();
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
const btn = e.currentTarget;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dragState = {
|
||||
btn: btn,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
moved: false
|
||||
};
|
||||
// Prevent text selection during drag
|
||||
e.preventDefault();
|
||||
document.addEventListener("mousemove", onDragMove);
|
||||
document.addEventListener("mouseup", onDragEnd);
|
||||
btn.classList.add("fd-dragging");
|
||||
}
|
||||
|
||||
function onDragMove(e) {
|
||||
if (!dragState) return;
|
||||
const dx = Math.abs(e.clientX - dragState.startX);
|
||||
const dy = Math.abs(e.clientY - dragState.startY);
|
||||
if (dx > 3 || dy > 3) dragState.moved = true;
|
||||
const btn = dragState.btn;
|
||||
let left = e.clientX - dragState.offsetX;
|
||||
let top = e.clientY - dragState.offsetY;
|
||||
const maxLeft = window.innerWidth - btn.offsetWidth - 4;
|
||||
const maxTop = window.innerHeight - btn.offsetHeight - 4;
|
||||
left = Math.max(4, Math.min(left, maxLeft));
|
||||
top = Math.max(4, Math.min(top, maxTop));
|
||||
btn.style.left = left + "px";
|
||||
btn.style.top = top + "px";
|
||||
btn.style.right = "auto";
|
||||
btn.style.bottom = "auto";
|
||||
}
|
||||
|
||||
async function onDragEnd(e) {
|
||||
document.removeEventListener("mousemove", onDragMove);
|
||||
document.removeEventListener("mouseup", onDragEnd);
|
||||
if (!dragState) return;
|
||||
const btn = dragState.btn;
|
||||
btn.classList.remove("fd-dragging");
|
||||
const moved = dragState.moved;
|
||||
dragState = null;
|
||||
if (moved) {
|
||||
// Suppress next click
|
||||
btn.dataset.dragged = "1";
|
||||
setTimeout(() => { btn.dataset.dragged = "0"; }, 400);
|
||||
const left = parseInt(btn.style.left, 10);
|
||||
const top = parseInt(btn.style.top, 10);
|
||||
if (!isNaN(left) && !isNaN(top)) {
|
||||
try {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
const cfg = data[STORAGE_KEY] || {};
|
||||
cfg.pos = { left: left, top: top };
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
||||
} catch (_e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to storage changes (show/hide toggle & position sync across tabs)
|
||||
try {
|
||||
if (chrome.storage && chrome.storage.onChanged) {
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== "local" || !changes[STORAGE_KEY]) return;
|
||||
const nv = changes[STORAGE_KEY].newValue || {};
|
||||
const ov = changes[STORAGE_KEY].oldValue || {};
|
||||
if (nv.showButton !== ov.showButton) {
|
||||
applyVisibility(nv.showButton !== false);
|
||||
}
|
||||
// Position already handled via direct style; if changed elsewhere sync it
|
||||
if (nv.pos && JSON.stringify(nv.pos) !== JSON.stringify(ov.pos)) {
|
||||
const el = document.getElementById(BTN_ID);
|
||||
if (el && nv.pos.left != null) {
|
||||
el.style.left = nv.pos.left + "px";
|
||||
el.style.top = nv.pos.top + "px";
|
||||
el.style.right = "auto";
|
||||
el.style.bottom = "auto";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
function getSelectionHtml() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.toString().trim().length < 3) return "";
|
||||
const range = sel.getRangeAt(0);
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(range.cloneContents());
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function openClipper(type) {
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = getSelectionHtml();
|
||||
const selectionText = window.getSelection() ? window.getSelection().toString().trim() : "";
|
||||
let clipType = type;
|
||||
if (selectionHtml && selectionText.length > 20) clipType = "selection";
|
||||
const payload = {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
content: html,
|
||||
content_type: clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_title: document.querySelector('meta[property="og:title"]')?.content || "",
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || "",
|
||||
author: document.querySelector('meta[name="author"]')?.content || ""
|
||||
}
|
||||
};
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: payload });
|
||||
if (resp && resp.error === "not_authenticated") {
|
||||
alert("FlowDeck: not authenticated. Open FlowDeck settings to connect the clipper.");
|
||||
} else if (resp && resp.status === "ok") {
|
||||
showToast("Clipped to FlowDeck \u2713 \u2014 page " + resp.page_id);
|
||||
} else if (resp && resp.error) {
|
||||
showToast("Clip failed: " + resp.error, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("FlowDeck clipper", e);
|
||||
showToast("Clip failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
let t = document.getElementById("fd-clipper-toast");
|
||||
if (!t) {
|
||||
t = document.createElement("div");
|
||||
t.id = "fd-clipper-toast";
|
||||
t.className = "fd-clipper-toast";
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.background = isError ? "#e03e3e" : "#1a7f37";
|
||||
t.classList.add("show");
|
||||
setTimeout(() => t.classList.remove("show"), 3000);
|
||||
}
|
||||
|
||||
// Runtime messages: from background (context menu, refresh) or popup
|
||||
try {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.action) return;
|
||||
if (message.action === "clipSelection") {
|
||||
openClipper("selection");
|
||||
try { sendResponse({ ok: true }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
if (message.action === "fdRefreshTree" || message.action === "refreshTree" || message.action === "fdClipCreated") {
|
||||
// FlowDeck page refresh signal (injected on FlowDeck origin)
|
||||
try {
|
||||
if (window.appState && typeof window.appState.refreshSidebarTree === "function") {
|
||||
window.appState.refreshSidebarTree();
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent("flowdeck:clip-created", { detail: message.data || {} }));
|
||||
document.dispatchEvent(new CustomEvent("flowdeck:workspace-changed"));
|
||||
// Fallback: try calling global
|
||||
if (window.FlowDeck && window.FlowDeck._toast) {}
|
||||
}
|
||||
// Also try direct fetch fallback
|
||||
const ul = document.getElementById("sidebar-workspace-items");
|
||||
if (ul && window.appState && window.appState.refreshSidebarTree) {
|
||||
window.appState.refreshSidebarTree();
|
||||
}
|
||||
} catch (_e) {}
|
||||
try { sendResponse({ ok: true }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
if (message.action === "toggleButton") {
|
||||
applyVisibility(message.show);
|
||||
try { sendResponse({ ok: true }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
if (message.action === "getSelectionHtml") {
|
||||
try { sendResponse({ html: getSelectionHtml() }); } catch (_e) {}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} catch (_e) {}
|
||||
|
||||
// Keyboard shortcut Ctrl+Shift+C
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
openClipper("article");
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for popup / background scripting
|
||||
window.__fdOpenClipper = openClipper;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createFloatButton);
|
||||
} else {
|
||||
createFloatButton();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FlowDeck Web Clipper",
|
||||
"description": "Capturez du contenu web directement dans FlowDeck",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["activeTab", "storage", "scripting", "contextMenus"],
|
||||
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["clipper.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["readability.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;width:340px;padding:16px;background:#191919;color:#e0e0e0}
|
||||
h2{font-size:15px;margin-bottom:6px}
|
||||
.desc{font-size:12px;color:#999;margin-bottom:12px;line-height:1.4}
|
||||
label{font-size:11px;color:#999;text-transform:uppercase;letter-spacing:.5px;display:block;margin-top:10px;margin-bottom:4px}
|
||||
input{width:100%;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:6px;color:#fff;padding:8px 10px;font-size:13px;outline:none}
|
||||
input:focus{border-color:#2383E2}
|
||||
.btn{width:100%;margin-top:12px;padding:9px;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.btn-primary{background:#2383E2;color:#fff}
|
||||
.btn-secondary{background:#2a2a2a;color:#ccc;border:1px solid #3a3a3a}
|
||||
.btn:active{opacity:.9}
|
||||
.row{display:flex;gap:8px;margin-top:10px}
|
||||
.row .btn{flex:1}
|
||||
.status{margin-top:10px;padding:8px 10px;border-radius:6px;font-size:12px;display:none}
|
||||
.status.ok{background:rgba(46,160,67,.15);color:#2ea043;display:block}
|
||||
.status.err{background:rgba(224,62,62,.15);color:#e03e3e;display:block}
|
||||
.status.warn{background:rgba(217,115,13,.15);color:#d9730d;display:block}
|
||||
.small{font-size:11px;color:#777;margin-top:6px}
|
||||
a{color:#2383E2;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>📌 FlowDeck Clipper</h2>
|
||||
<p class="desc">Capture any page into FlowDeck in one click.</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" id="clipArticle">Clip article</button>
|
||||
<button class="btn btn-secondary" id="clipBookmark">Bookmark</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="clipSelection" style="margin-top:8px;">Clip selection</button>
|
||||
|
||||
<label>FlowDeck server URL</label>
|
||||
<input id="serverUrl" placeholder="http://localhost:8080">
|
||||
|
||||
<label>Bearer token <span style="text-transform:none;color:#666">(Settings → API tokens)</span></label>
|
||||
<input id="token" placeholder="fd_... or fd_clip_..." type="password">
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button class="btn btn-secondary" id="save" style="flex:1;padding:7px;font-size:12px;">Save</button>
|
||||
<button class="btn btn-secondary" id="verify" style="flex:1;padding:7px;font-size:12px;">Verify</button>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer;text-transform:none;letter-spacing:0;font-size:13px;color:#e0e0e0"><input type="checkbox" id="showButton" style="width:auto;accent-color:#2383E2"> Afficher le bouton flottant sur les pages</label>
|
||||
<p class="small" style="margin-left:22px">Rond, semi-transparent, déplaçable à la souris.</p>
|
||||
<div id="status" class="status"></div>
|
||||
<p class="small">Need a token? Open <a href="#" id="openFlowDeck">FlowDeck → Settings → API tokens</a> or <a href="#" id="openExtensions">Extensions</a>.</p>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
// Popup logic
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function load() {
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
$("serverUrl").value = cfg.serverUrl || "http://localhost:8080";
|
||||
$("token").value = cfg.token || "";
|
||||
const sb = $("showButton");
|
||||
if (sb) sb.checked = cfg.showButton !== false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const serverUrl = $("serverUrl").value.trim() || "http://localhost:8080";
|
||||
const token = $("token").value.trim();
|
||||
const showButton = $("showButton") ? $("showButton").checked : true;
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
cfg.serverUrl = serverUrl.replace(/\/$/, "");
|
||||
cfg.token = token;
|
||||
cfg.showButton = showButton;
|
||||
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
|
||||
show("Saved ✓", "ok");
|
||||
}
|
||||
|
||||
async function toggleButton(show) {
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
cfg.showButton = show;
|
||||
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
|
||||
// Notify all tabs immediately for instant UX
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
for (const t of tabs) {
|
||||
if (!t.id) continue;
|
||||
chrome.tabs.sendMessage(t.id, { action: "toggleButton", show }).catch(() => {});
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await save();
|
||||
const res = await chrome.runtime.sendMessage({ action: "auth" });
|
||||
if (res && res.authenticated) show(`Connected as ${res.user?.login || "user"} — ${res.devices} device(s)`, "ok");
|
||||
else if (res && res.authenticated === false) show("Not authenticated — check token / server URL", "err");
|
||||
else show(JSON.stringify(res).slice(0, 200), "warn");
|
||||
}
|
||||
|
||||
function show(msg, kind) {
|
||||
const el = $("status");
|
||||
el.textContent = msg;
|
||||
el.className = "status " + kind;
|
||||
}
|
||||
|
||||
async function doClip(type) {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) return;
|
||||
// Get page data via scripting
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (clipType) => {
|
||||
const sel = window.getSelection();
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = (() => {
|
||||
if (!sel || sel.rangeCount === 0) return "";
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
return d.innerHTML;
|
||||
})();
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
html: html,
|
||||
content_type: sel && sel.toString().trim().length > 20 ? "selection" : clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || ""
|
||||
}
|
||||
};
|
||||
},
|
||||
args: [type]
|
||||
});
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: result });
|
||||
if (resp && resp.status === "ok") show(`Clipped → page ${resp.page_id} ✓`, "ok");
|
||||
else show(resp.detail || resp.error || "Clip failed", "err");
|
||||
}
|
||||
|
||||
$("save").addEventListener("click", save);
|
||||
$("verify").addEventListener("click", verify);
|
||||
$("clipArticle").addEventListener("click", () => doClip("article"));
|
||||
$("clipBookmark").addEventListener("click", () => doClip("bookmark"));
|
||||
$("clipSelection").addEventListener("click", () => doClip("selection"));
|
||||
$("openFlowDeck").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: $("serverUrl").value || "http://localhost:8080" }); });
|
||||
$("openExtensions").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: ($("serverUrl").value || "http://localhost:8080") + "/extensions" }); });
|
||||
const _sb = $("showButton");
|
||||
if (_sb) _sb.addEventListener("change", (e) => toggleButton(e.target.checked));
|
||||
|
||||
load();
|
||||
@@ -26,6 +26,7 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
os.environ["BACKUP_ENABLED"] = "true"
|
||||
os.environ["BACKUP_DIR"] = backup_dir
|
||||
os.environ["PROJECT_SYNC_ENABLED"] = "false"
|
||||
@@ -43,6 +44,7 @@ def client():
|
||||
s.database_url = f"sqlite:///{db_path}"
|
||||
s.app_secret_key = "test-secret-for-tests"
|
||||
s.rate_limit_enabled = False
|
||||
s.public_api_insecure_ok = True
|
||||
s.backup_enabled = True
|
||||
s.backup_dir = backup_dir
|
||||
s.backup_interval_hours = 24
|
||||
|
||||
@@ -18,11 +18,13 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
|
||||
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.rate_limit_enabled = False
|
||||
settings.public_api_insecure_ok = True
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
@@ -40,7 +42,10 @@ def client():
|
||||
|
||||
yield TestClient(app)
|
||||
|
||||
os.unlink(db_path)
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except PermissionError:
|
||||
pass # Windows: file may still be open in another thread
|
||||
|
||||
|
||||
# ── Core ──
|
||||
|
||||
@@ -16,11 +16,13 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
|
||||
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.rate_limit_enabled = False
|
||||
settings.public_api_insecure_ok = True
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""FlowDeck — Public API v2 tests (v6.3.0).
|
||||
|
||||
Covers auth + scopes, tokens CRUD, pagination, filters, RFC7807 errors,
|
||||
idempotency, webhooks CRUD, search, and the main resource wrappers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _register_and_token(client, login="apiuser"):
|
||||
"""Create a local account, log in, and mint a v2-capable token.
|
||||
|
||||
Uses the legacy /api/v1/token endpoint (session-authenticated) to obtain a
|
||||
first token, then exercises v2.
|
||||
"""
|
||||
r = client.post("/auth/register", json={
|
||||
"email": f"{login}@test.dev", "password": "secret123", "name": login,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
tok = client.post("/api/v1/token").json()["token"]
|
||||
return {"Authorization": f"Bearer {tok}"}, tok
|
||||
|
||||
|
||||
def _v2_token(client, headers, scopes="read,write", name="ci"):
|
||||
r = client.post("/api/v2/tokens", json={"name": name, "scopes": scopes}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["token"]
|
||||
|
||||
|
||||
# ── Auth ──
|
||||
|
||||
def test_v2_requires_bearer(client):
|
||||
r = client.get("/api/v2/collections")
|
||||
assert r.status_code == 401
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert r.json()["status"] == 401
|
||||
|
||||
|
||||
def test_v2_rejects_invalid_token(client):
|
||||
r = client.get("/api/v2/users/me", headers={"Authorization": "Bearer nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_v2_fd_public_key_allowed_in_dev(client):
|
||||
"""conftest sets PUBLIC_API_INSECURE_OK=true → dev fallback works."""
|
||||
r = client.get("/api/v2/users/me", headers={"Authorization": "Bearer fd-public-key"})
|
||||
# no users exist yet except seeded tester; may be 200 if a user exists
|
||||
assert r.status_code in (200, 401)
|
||||
|
||||
|
||||
def test_v2_me(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.get("/api/v2/users/me", headers=headers)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["login"] == "[email protected]"
|
||||
assert "password_hash" not in d
|
||||
|
||||
|
||||
# ── Tokens CRUD + scopes ──
|
||||
|
||||
def test_v2_token_lifecycle(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.post("/api/v2/tokens", json={"name": "CI", "scopes": "read,write"}, headers=headers)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["token"].startswith("fd_")
|
||||
assert data["scopes"] == "read,write"
|
||||
tid = data["id"]
|
||||
|
||||
listing = client.get("/api/v2/tokens", headers=headers).json()["tokens"]
|
||||
assert any(t["id"] == tid for t in listing)
|
||||
# never expose hash
|
||||
assert all("token_hash" not in t for t in listing)
|
||||
|
||||
rev = client.delete(f"/api/v2/tokens/{tid}", headers=headers)
|
||||
assert rev.status_code == 200
|
||||
|
||||
|
||||
def test_v2_invalid_scope_rejected(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.post("/api/v2/tokens", json={"name": "bad", "scopes": "superuser"}, headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_v2_read_only_scope_blocks_write(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
ro = _v2_token(client, headers, scopes="read", name="ro")
|
||||
h_ro = {"Authorization": f"Bearer {ro}"}
|
||||
r = client.post("/api/v2/collections", json={"name": "Nope"}, headers=h_ro)
|
||||
assert r.status_code == 403
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
|
||||
|
||||
def test_v2_admin_scope_implies_write(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
admin = _v2_token(client, headers, scopes="admin", name="admin")
|
||||
h = {"Authorization": f"Bearer {admin}"}
|
||||
r = client.post("/api/v2/collections", json={"name": "AdminDB"}, headers=h)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
# ── Collections / pages / properties / views ──
|
||||
|
||||
def test_v2_collections_crud(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.post("/api/v2/collections", json={"name": "DB1", "description": "d"}, headers=headers)
|
||||
assert r.status_code == 201
|
||||
cid = r.json()["id"]
|
||||
|
||||
got = client.get(f"/api/v2/collections/{cid}", headers=headers)
|
||||
assert got.status_code == 200
|
||||
assert got.json()["name"] == "DB1"
|
||||
|
||||
patched = client.patch(f"/api/v2/collections/{cid}", json={"name": "DB1b"}, headers=headers)
|
||||
assert patched.status_code == 200
|
||||
assert client.get(f"/api/v2/collections/{cid}", headers=headers).json()["name"] == "DB1b"
|
||||
|
||||
deleted = client.delete(f"/api/v2/collections/{cid}", headers=headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
|
||||
def test_v2_pagination_and_total_count(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
for i in range(3):
|
||||
client.post("/api/v2/collections", json={"name": f"P{i}"}, headers=headers)
|
||||
r = client.get("/api/v2/collections?limit=2&offset=0", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["X-Total-Count"] == "3"
|
||||
body = r.json()
|
||||
assert len(body["collections"]) == 2
|
||||
assert body["limit"] == 2 and body["offset"] == 0
|
||||
|
||||
|
||||
def test_v2_pages_properties_views(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
cid = client.post("/api/v2/collections", json={"name": "Work"}, headers=headers).json()["id"]
|
||||
|
||||
page = client.post(f"/api/v2/collections/{cid}/pages", json={"title": "Task 1"}, headers=headers)
|
||||
assert page.status_code == 201
|
||||
pid = page.json()["id"]
|
||||
assert client.get(f"/api/v2/pages/{pid}", headers=headers).json()["title"] == "Task 1"
|
||||
|
||||
assert client.patch(f"/api/v2/pages/{pid}", json={"title": "Task 1b"}, headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/pages/{pid}", headers=headers).json()["title"] == "Task 1b"
|
||||
|
||||
prop = client.post(f"/api/v2/collections/{cid}/properties",
|
||||
json={"name": "Status", "prop_type": "select", "options": ["Todo", "Done"]},
|
||||
headers=headers)
|
||||
assert prop.status_code == 200
|
||||
assert client.get(f"/api/v2/collections/{cid}/properties", headers=headers).status_code == 200
|
||||
|
||||
assert client.get(f"/api/v2/collections/{cid}/views", headers=headers).status_code == 200
|
||||
assert client.delete(f"/api/v2/pages/{pid}", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_v2_page_filter_and_sort(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
cid = client.post("/api/v2/collections", json={"name": "F"}, headers=headers).json()["id"]
|
||||
client.post(f"/api/v2/collections/{cid}/pages", json={"title": "Alpha"}, headers=headers)
|
||||
client.post(f"/api/v2/collections/{cid}/pages", json={"title": "Beta"}, headers=headers)
|
||||
r = client.get(f"/api/v2/collections/{cid}/pages?filter[title]=Alpha", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["pages"]) == 1
|
||||
|
||||
|
||||
# ── RFC7807 + idempotency ──
|
||||
|
||||
def test_v2_error_is_problem_json(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.get("/api/v2/pages/999999", headers=headers)
|
||||
assert r.status_code == 404
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
body = r.json()
|
||||
assert body["type"] and body["title"] and body["status"] == 404 and body["instance"]
|
||||
|
||||
|
||||
def test_v2_idempotency_key(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
h = {**headers, "Idempotency-Key": "abc-123"}
|
||||
r1 = client.post("/api/v2/collections", json={"name": "Idem"}, headers=h)
|
||||
r2 = client.post("/api/v2/collections", json={"name": "Idem"}, headers=h)
|
||||
assert r1.status_code == 201 and r2.status_code == 201
|
||||
assert r1.json()["id"] == r2.json()["id"]
|
||||
|
||||
|
||||
# ── Search / notifications / favorites / tags / sharing / history ──
|
||||
|
||||
def test_v2_search(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
client.post("/api/v2/collections", json={"name": "Searchable"}, headers=headers)
|
||||
r = client.get("/api/v2/search?query=Search", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert any("Searchable" in x["title"] for x in r.json()["results"])
|
||||
|
||||
|
||||
def test_v2_notifications(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.get("/api/v2/notifications", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/v2/notifications/unread-count", headers=headers).status_code == 200
|
||||
assert client.post("/api/v2/notifications/read-all", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_v2_favorites_tags_recents(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
# A `pages` row is needed for favorites FK; collection pages aren't `pages`
|
||||
# rows and v2 doesn't expose board page creation, so test tags + recents.
|
||||
client.post("/api/v2/collections", json={"name": "Fav"}, headers=headers)
|
||||
t = client.post("/api/v2/tags", json={"name": "urgent", "color": "#f00"}, headers=headers)
|
||||
assert t.status_code == 200
|
||||
assert any(x["name"] == "urgent" for x in client.get("/api/v2/tags", headers=headers).json()["tags"])
|
||||
assert client.get("/api/v2/recents", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_v2_sharing_and_history(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
# sharing requires a pages row; create via internal API with the session
|
||||
r = client.post("/board/api/pages?section=Private&project=test/test")
|
||||
if r.status_code != 200:
|
||||
pytest.skip("board page creation unavailable")
|
||||
pid = r.json()["id"]
|
||||
sh = client.post(f"/api/v2/pages/{pid}/shares",
|
||||
json={"email": "[email protected]", "permission": "view"}, headers=headers)
|
||||
assert sh.status_code == 200
|
||||
assert client.get(f"/api/v2/pages/{pid}/shares", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/pages/{pid}/history", headers=headers).status_code == 200
|
||||
|
||||
|
||||
# ── Webhooks CRUD ──
|
||||
|
||||
def test_v2_webhooks_crud(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.post("/api/v2/webhooks",
|
||||
json={"url": "https://example.com/hook", "event": "page.created"}, headers=headers)
|
||||
assert r.status_code == 200
|
||||
wid = r.json()["id"]
|
||||
assert any(w["id"] == wid for w in client.get("/api/v2/webhooks", headers=headers).json()["webhooks"])
|
||||
assert client.post(f"/api/v2/webhooks/{wid}/test", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/webhooks/{wid}/deliveries", headers=headers).status_code == 200
|
||||
assert client.delete(f"/api/v2/webhooks/{wid}", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_v2_webhooks_invalid_url(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.post("/api/v2/webhooks", json={"url": "ftp://x", "event": "page.created"}, headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Workspaces / sprints / templates ──
|
||||
|
||||
def test_v2_workspaces_crud(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.post("/api/v2/workspaces", json={"name": "Team"}, headers=headers)
|
||||
assert r.status_code == 201
|
||||
wid = r.json()["id"]
|
||||
assert client.get(f"/api/v2/workspaces/{wid}", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/workspaces/{wid}/members", headers=headers).status_code == 200
|
||||
assert client.patch(f"/api/v2/workspaces/{wid}", json={"name": "Team2"}, headers=headers).status_code == 200
|
||||
assert client.delete(f"/api/v2/workspaces/{wid}", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_v2_sprints(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
cid = client.post("/api/v2/collections", json={"name": "S"}, headers=headers).json()["id"]
|
||||
r = client.post(f"/api/v2/collections/{cid}/sprints",
|
||||
json={"name": "Sprint 1", "start_date": "2026-01-01", "end_date": "2026-01-15"},
|
||||
headers=headers)
|
||||
assert r.status_code == 200
|
||||
sid = r.json()["id"]
|
||||
assert client.get(f"/api/v2/collections/{cid}/sprints", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/sprints/{sid}/burndown", headers=headers).status_code == 200
|
||||
assert client.delete(f"/api/v2/sprints/{sid}", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_v2_templates_database(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.get("/api/v2/templates/database", headers=headers)
|
||||
assert r.status_code == 200
|
||||
tpls = r.json()["templates"]
|
||||
if tpls:
|
||||
applied = client.post(f"/api/v2/templates/database/{tpls[0]['id']}/apply", json={}, headers=headers)
|
||||
assert applied.status_code == 200
|
||||
|
||||
|
||||
def test_v2_admin_requires_admin(client):
|
||||
headers, _ = _register_and_token(client)
|
||||
r = client.get("/api/v2/admin/users", headers=headers)
|
||||
# first registered user is admin; this endpoint allows admin OR admin scope
|
||||
assert r.status_code in (200, 403)
|
||||
@@ -166,9 +166,11 @@ def test_e2e_pwa_offline_spec_present():
|
||||
# ── phase 8: release docs + version ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_version_bumped_to_6_0_0():
|
||||
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "6.0.0"
|
||||
assert 'version="6.0.0"' in (ROOT / "app" / "main.py").read_text(encoding="utf-8")
|
||||
def test_version_at_least_6_0_0():
|
||||
"""VERSION and app/main.py agree and are >= 6.0.0 (PWA release baseline)."""
|
||||
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert tuple(int(x) for x in version.split(".")) >= (6, 0, 0)
|
||||
assert f'version="{version}"' in (ROOT / "app" / "main.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_help_documents_offline_mode(client):
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""FlowDeck — v6.4.0 Realtime production: merge 3-voix (au-delà du LWW),
|
||||
broadcast non bloquant + coalescence des curseurs, corrections de fuites de
|
||||
rooms, anti-flood et observabilité.
|
||||
|
||||
Covers :
|
||||
* ``realtime_merge`` — merge_text_3way / merge_block_3way (purs, sans WS)
|
||||
* protocole WS avec ``base`` → ack fusionné + drapeau conflict
|
||||
* convergence de deux clients sur le même bloc (les deux saisies survivent)
|
||||
* le LWW historique est préservé quand il n'y a pas de ``base``
|
||||
* room 4404 non enregistrée (fuite corrigée), ``room_state`` page inexistante
|
||||
* coalescence des curseurs via la file sortante
|
||||
* ``GET /api/realtime/stats``
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-realtime-v64"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
|
||||
from app.db import get_conn, init_db
|
||||
from app.main import app
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (2, 'other', 'Other', 0)")
|
||||
conn.commit()
|
||||
|
||||
from app.services.realtime_server import manager
|
||||
manager._rooms = {}
|
||||
manager.stat_ops = 0
|
||||
manager.stat_merges = 0
|
||||
manager.stat_conflicts = 0
|
||||
manager.stat_slow_disconnects = 0
|
||||
manager.stat_connections_total = 0
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def _make_page(title="V64 Page", blocks=None):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('Private', ?, ?, 'blocks', 'Private')",
|
||||
(title, json.dumps(blocks or [], ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _token(user_id, login):
|
||||
from app.auth.session import SessionManager
|
||||
return SessionManager.create_session({"id": user_id, "login": login,
|
||||
"full_name": login.title(), "is_admin": 1})
|
||||
|
||||
|
||||
def _auth_client(client, user_id=1, login="tester"):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||
|
||||
|
||||
# ── realtime_merge (purs) ────────────────────────────────────────────────
|
||||
|
||||
def test_merge_text_disjoint_edits_survive():
|
||||
from app.services.realtime_merge import merge_text_3way
|
||||
base = "hello world"
|
||||
cur = "hello brave world" # serveur : insert "brave"
|
||||
inc = "hello world today" # client : append "today"
|
||||
out, conflict = merge_text_3way(base, cur, inc)
|
||||
assert conflict is False
|
||||
assert "brave" in out and "today" in out
|
||||
|
||||
|
||||
def test_merge_text_disjoint_edits_order_independent():
|
||||
from app.services.realtime_merge import merge_text_3way
|
||||
base = "hello world"
|
||||
a, _ = merge_text_3way(base, "hello brave world", "hello world today")
|
||||
b, _ = merge_text_3way(base, "hello world today", "hello brave world")
|
||||
assert a == b == "hello brave world today"
|
||||
|
||||
|
||||
def test_merge_text_real_overlap_flags_conflict_lww():
|
||||
from app.services.realtime_merge import merge_text_3way
|
||||
out, conflict = merge_text_3way("hello", "hxllo", "hyllo")
|
||||
assert conflict is True
|
||||
assert out == "hyllo" # LWW : incoming gagne
|
||||
|
||||
|
||||
def test_merge_text_shortcuts():
|
||||
from app.services.realtime_merge import merge_text_3way
|
||||
assert merge_text_3way("a", "a", "b") == ("b", False) # client a changé
|
||||
assert merge_text_3way("a", "b", "a") == ("b", False) # serveur a changé
|
||||
assert merge_text_3way("a", "b", "b") == ("b", False) # même changement
|
||||
|
||||
|
||||
def test_merge_text_same_point_insertion_not_destructive():
|
||||
from app.services.realtime_merge import merge_text_3way
|
||||
out, conflict = merge_text_3way("ab", "aXb", "aYb")
|
||||
# les deux insertions au même index sont conservées (concaténées)
|
||||
assert "X" in out and "Y" in out
|
||||
assert conflict is False
|
||||
|
||||
|
||||
def test_changed_region():
|
||||
from app.services.realtime_merge import changed_region
|
||||
assert changed_region("hello", "hello") is None
|
||||
assert changed_region("hello world", "hello brave world") == (6, 6, "brave ")
|
||||
assert changed_region("abc", "abXc") == (2, 2, "X")
|
||||
|
||||
|
||||
def test_merge_block_field_level_no_conflict():
|
||||
"""Serveur et client changent des champs différents → pas de conflit."""
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
base = {"id": "1", "text": "hi", "done": False}
|
||||
cur = {"id": "1", "text": "hi there", "done": False} # serveur édite text
|
||||
inc = {"id": "1", "text": "hi", "done": True} # client coche done
|
||||
merged, conflicts = merge_block_3way(base, cur, inc)
|
||||
assert merged["text"] == "hi there"
|
||||
assert merged["done"] is True
|
||||
assert conflicts == []
|
||||
|
||||
|
||||
def test_merge_block_text_disjoint_merges():
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
base = {"id": "1", "text": "hello world"}
|
||||
cur = {"id": "1", "text": "hello brave world"}
|
||||
inc = {"id": "1", "text": "hello world today"}
|
||||
merged, conflicts = merge_block_3way(base, cur, inc)
|
||||
assert conflicts == []
|
||||
assert "brave" in merged["text"] and "today" in merged["text"]
|
||||
|
||||
|
||||
def test_merge_block_scalar_conflict_lww():
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
merged, conflicts = merge_block_3way({"id": "1", "n": 1},
|
||||
{"id": "1", "n": 2},
|
||||
{"id": "1", "n": 3})
|
||||
assert merged["n"] == 3
|
||||
assert "n" in conflicts
|
||||
|
||||
|
||||
def test_merge_block_server_deleted_field_stays_deleted():
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
base = {"id": "1", "tmp": "x"}
|
||||
cur = {"id": "1"} # serveur a supprimé tmp
|
||||
inc = {"id": "1", "tmp": "x"} # client n'a pas touché tmp
|
||||
merged, _ = merge_block_3way(base, cur, inc)
|
||||
assert "tmp" not in merged
|
||||
|
||||
|
||||
def test_merge_block_incoming_non_dict_flagged():
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
_, conflicts = merge_block_3way({"id": "1"}, {"id": "1"}, "junk")
|
||||
assert conflicts == ["__block__"]
|
||||
|
||||
|
||||
def test_merge_block_preserves_id_when_base_empty():
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
merged, _ = merge_block_3way({}, {}, {"id": "z", "text": "new"})
|
||||
assert merged["id"] == "z"
|
||||
|
||||
|
||||
# ── apply_op (LWW historique préservé) ───────────────────────────────────
|
||||
|
||||
def test_apply_op_still_lww_without_base():
|
||||
from app.services.realtime_server import apply_op
|
||||
blocks = [{"id": "a", "content": "A"}]
|
||||
out = apply_op(blocks, {"type": "update", "block": {"id": "a", "content": "B"}})
|
||||
assert out[0]["content"] == "B"
|
||||
out = apply_op(blocks, {"type": "delete", "id": "a"})
|
||||
assert out == []
|
||||
out = apply_op(blocks, {"type": "move", "id": "a", "index": 0})
|
||||
assert [b["id"] for b in out] == ["a"]
|
||||
|
||||
|
||||
# ── protocole WS ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_ws_requires_auth(client):
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_ws_missing_page_4404(client):
|
||||
_auth_client(client)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/9999") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4404
|
||||
|
||||
|
||||
def test_ws_4404_does_not_leak_room(client):
|
||||
"""Une tentative de connexion sur une page inexistante ne doit pas laisser
|
||||
de Room orpheline en mémoire (fuite corrigée en v6.4.0)."""
|
||||
from app.services.realtime_server import manager
|
||||
_auth_client(client)
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect("/ws/pages/9999") as ws:
|
||||
ws.receive_json()
|
||||
assert 9999 not in manager._rooms, f"room leak: {list(manager._rooms)}"
|
||||
|
||||
|
||||
def test_ws_hello_gets_sync(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "Hi"}])
|
||||
_auth_client(client)
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
w = ws.receive_json()
|
||||
assert w["t"] == "welcome"
|
||||
s = ws.receive_json()
|
||||
assert s["t"] == "sync"
|
||||
assert s["blocks"][0]["id"] == "a"
|
||||
ws.send_json({"t": "hello"})
|
||||
s2 = ws.receive_json()
|
||||
assert s2["t"] == "sync" and s2["blocks"][0]["content"] == "Hi"
|
||||
|
||||
|
||||
def test_ws_update_with_base_returns_merged(client):
|
||||
"""Update avec base → ack fusionné, pas de conflit si seul le client a changé."""
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"}])
|
||||
_auth_client(client)
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
ws.receive_json() # welcome
|
||||
ws.receive_json() # sync
|
||||
ws.send_json({"t": "op", "v": 0, "op": {
|
||||
"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "x!"},
|
||||
"base": {"id": "a", "type": "paragraph", "content": "x"},
|
||||
}})
|
||||
ack = ws.receive_json()
|
||||
assert ack["t"] == "ack"
|
||||
assert ack.get("merged")["content"] == "x!"
|
||||
assert ack.get("conflict") is False
|
||||
|
||||
|
||||
def test_ws_two_clients_same_block_merge_disjoint(client):
|
||||
"""Deux clients éditent le même bloc à des endroits différents → les deux
|
||||
saisies survivent (au-delà du LWW) et convergeamment."""
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "hello world"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json() # welcome
|
||||
wa.receive_json() # sync
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json() # welcome
|
||||
wb.receive_json() # sync
|
||||
wa.receive_json() # peer_join
|
||||
|
||||
# A édite la fin
|
||||
wa.send_json({"t": "op", "v": 1, "op": {
|
||||
"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "hello world today"},
|
||||
"base": {"id": "a", "type": "paragraph", "content": "hello world"},
|
||||
}})
|
||||
ack_a = wa.receive_json()
|
||||
assert ack_a["t"] == "ack"
|
||||
op_b = wb.receive_json()
|
||||
assert op_b["t"] == "op"
|
||||
|
||||
# B édite le début, sur la même base
|
||||
wb.send_json({"t": "op", "v": 1, "op": {
|
||||
"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "hello brave world"},
|
||||
"base": {"id": "a", "type": "paragraph", "content": "hello world"},
|
||||
}})
|
||||
ack_b = wb.receive_json()
|
||||
assert ack_b["t"] == "ack"
|
||||
merged = ack_b["merged"]["content"]
|
||||
# les deux éditions disjointes doivent être conservées
|
||||
assert "brave" in merged, merged
|
||||
assert "today" in merged, merged
|
||||
assert ack_b["conflict"] is False
|
||||
|
||||
# A reçoit aussi le bloc fusionné
|
||||
op_a2 = wa.receive_json()
|
||||
assert op_a2["t"] == "op"
|
||||
assert "brave" in op_a2["op"]["block"]["content"]
|
||||
assert "today" in op_a2["op"]["block"]["content"]
|
||||
|
||||
# persistance du contenu fusionné
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
d = json.loads(row["content"])
|
||||
assert "brave" in d[0]["content"] and "today" in d[0]["content"]
|
||||
|
||||
|
||||
def test_ws_update_without_base_stays_lww(client):
|
||||
"""Sans ``base`` (ancien client / protocole), le LWW historique s'applique."""
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json()
|
||||
wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json()
|
||||
wb.receive_json()
|
||||
wa.receive_json() # peer_join
|
||||
wa.send_json({"t": "op", "v": 0, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "first"}}})
|
||||
wa.receive_json() # ack
|
||||
b_op = wb.receive_json()
|
||||
assert b_op["op"]["block"]["content"] == "first"
|
||||
wb.send_json({"t": "op", "v": 1, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "second"}}})
|
||||
wb.receive_json() # ack (pas de merged sans base)
|
||||
a_op = wa.receive_json()
|
||||
assert a_op["op"]["block"]["content"] == "second"
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert json.loads(row["content"])[0]["content"] == "second"
|
||||
|
||||
|
||||
def test_ws_conflicting_scalar_flagged(client):
|
||||
"""Conflit sur un champ scalaire multi-états → drapeau conflict=True + LWW.
|
||||
|
||||
Un booléen ne peut pas diverger depuis la même base (2 valeurs) ; on utilise
|
||||
un ``status`` à 3 états pour que les deux côtés changent *différemment*.
|
||||
"""
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "todo", "content": "t", "status": "todo"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json()
|
||||
wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json()
|
||||
wb.receive_json()
|
||||
wa.receive_json()
|
||||
# A passe todo → doing
|
||||
wa.send_json({"t": "op", "v": 1, "op": {
|
||||
"type": "update",
|
||||
"block": {"id": "a", "type": "todo", "content": "t", "status": "doing"},
|
||||
"base": {"id": "a", "type": "todo", "content": "t", "status": "todo"},
|
||||
}})
|
||||
wa.receive_json() # ack
|
||||
wb.receive_json() # op broadcast
|
||||
# B passe todo → done (conflit scalaire réel, même base)
|
||||
wb.send_json({"t": "op", "v": 1, "op": {
|
||||
"type": "update",
|
||||
"block": {"id": "a", "type": "todo", "content": "t", "status": "done"},
|
||||
"base": {"id": "a", "type": "todo", "content": "t", "status": "todo"},
|
||||
}})
|
||||
ack_b = wb.receive_json()
|
||||
assert ack_b["t"] == "ack"
|
||||
assert ack_b["conflict"] is True
|
||||
assert ack_b["merged"]["status"] == "done" # LWW : incoming gagne
|
||||
assert "status" in str(ack_b.get("merged"))
|
||||
|
||||
|
||||
def test_ws_stats_endpoint(client):
|
||||
_auth_client(client)
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"}])
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
ws.receive_json()
|
||||
ws.receive_json()
|
||||
ws.send_json({"t": "op", "v": 0, "op": {
|
||||
"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "y"},
|
||||
"base": {"id": "a", "type": "paragraph", "content": "x"},
|
||||
}})
|
||||
ws.receive_json() # ack
|
||||
r = client.get("/api/realtime/stats")
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["rooms"] == 1
|
||||
assert d["connections"] == 1
|
||||
assert d["ops"] >= 1
|
||||
assert d["merges"] >= 1
|
||||
assert isinstance(d["pages"], list) and d["pages"][0]["page_id"] == pid
|
||||
|
||||
|
||||
def test_ws_stats_requires_auth(client):
|
||||
r = client.get("/api/realtime/stats")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"error": "unauthorized"}
|
||||
|
||||
|
||||
def test_ws_op_budget_anti_flood(client):
|
||||
"""Au-delà du budget d'ops, le serveur répond ack stale (sans appliquer)."""
|
||||
from app.services import realtime_server as rs
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"}])
|
||||
_auth_client(client)
|
||||
old_max = rs.OP_WINDOW_MAX
|
||||
rs.OP_WINDOW_MAX = 3
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
ws.receive_json()
|
||||
ws.receive_json()
|
||||
for i in range(6):
|
||||
ws.send_json({"t": "op", "v": 0, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": f"v{i}"}}})
|
||||
acks = [ws.receive_json() for _ in range(6)]
|
||||
stale = [a for a in acks if a.get("stale")]
|
||||
assert stale, "budget anti-flood n'a pas été déclenché"
|
||||
finally:
|
||||
rs.OP_WINDOW_MAX = old_max
|
||||
|
||||
|
||||
def test_room_state_missing_page_returns_empty(client):
|
||||
import asyncio
|
||||
|
||||
from app.services.realtime_server import manager
|
||||
out = asyncio.run(manager.room_state(424242))
|
||||
assert out == {"blocks": [], "title": "", "version": 0}
|
||||
assert 424242 not in manager._rooms
|
||||
|
||||
|
||||
def test_writer_coalesces_cursor_updates():
|
||||
"""La file sortante d'une connexion coalesce les curseurs (une seule position
|
||||
finale conservée) tout en préservant l'ordre des messages importants."""
|
||||
import asyncio
|
||||
|
||||
class FakeWS:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
async def send_json(self, msg):
|
||||
self.sent.append(msg)
|
||||
|
||||
async def run():
|
||||
from app.services.realtime_server import RealtimeManager, RTConn
|
||||
ws = FakeWS()
|
||||
conn = RTConn(ws, {"id": 1, "login": "u"}, 1)
|
||||
conn.writer = asyncio.create_task(RealtimeManager()._writer(conn))
|
||||
# empiler plusieurs curseurs + un message important au milieu
|
||||
conn.out_q.put_nowait({"t": "sel", "block": "a", "offset": 1})
|
||||
conn.out_q.put_nowait({"t": "sel", "block": "a", "offset": 2})
|
||||
conn.out_q.put_nowait({"t": "title", "title": "T"})
|
||||
conn.out_q.put_nowait({"t": "sel", "block": "a", "offset": 3})
|
||||
await asyncio.sleep(0.05)
|
||||
conn.closed = True
|
||||
conn.out_q.put_nowait(None)
|
||||
await asyncio.sleep(0.02)
|
||||
if conn.writer and not conn.writer.done():
|
||||
conn.writer.cancel()
|
||||
return ws.sent
|
||||
|
||||
sent = asyncio.run(run())
|
||||
sels = [m for m in sent if m.get("t") == "sel"]
|
||||
titles = [m for m in sent if m.get("t") == "title"]
|
||||
# les curseurs sont coalescés : on ne garde pas les positions 1 et 2 séparées
|
||||
assert len(sels) <= 2, f"curseurs non coalescés: {sels}"
|
||||
assert titles and titles[0]["title"] == "T"
|
||||
# l'offset final (3) doit être présent
|
||||
assert sels[-1]["offset"] == 3, sels
|
||||
@@ -19,11 +19,13 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
|
||||
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.rate_limit_enabled = False
|
||||
settings.public_api_insecure_ok = True
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""FlowDeck — Partage de page par groupe (page_shares.shared_with_group_id).
|
||||
|
||||
Couvre : création d'un partage groupe, upsert anti-doublon,
|
||||
listage (kind=group, group_name), mise à jour de permission,
|
||||
suppression, et erreurs 404 (page / groupe inconnu).
|
||||
"""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
def _token(user_id, login="user", is_admin=1):
|
||||
return SessionManager.create_session(
|
||||
{"id": user_id, "login": login, "full_name": login.title(), "is_admin": is_admin}
|
||||
)
|
||||
|
||||
|
||||
def _ensure_owner():
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=1").fetchone()
|
||||
if not row:
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, login, full_name, email, is_admin) "
|
||||
"VALUES (1, 'owner', 'Owner', '[email protected]', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _as(client, user_id=1, login="owner"):
|
||||
_ensure_owner()
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||
|
||||
|
||||
def _insert_page(title="Group Share Page"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('Private', ?, '[]', 'blocks', 'Private')",
|
||||
(title,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_group(name="Team"):
|
||||
_ensure_owner()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO user_groups (workspace_id, name, description, created_by) "
|
||||
"VALUES (NULL, ?, '', 1)",
|
||||
(name,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def test_share_page_with_group(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
gid = _insert_group()
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "edit"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["status"] == "shared"
|
||||
assert d["shared_with_group_id"] == gid
|
||||
|
||||
|
||||
def test_share_page_group_upsert_no_duplicate(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
gid = _insert_group()
|
||||
r1 = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"})
|
||||
r2 = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "comment"})
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
assert r1.json()["id"] == r2.json()["id"]
|
||||
with get_conn() as conn:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||
(pid, gid),
|
||||
).fetchone()["c"]
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_list_shares_includes_group(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
gid = _insert_group(name="Editors")
|
||||
client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"})
|
||||
r = client.get(f"/api/pages/{pid}/shares")
|
||||
assert r.status_code == 200, r.text
|
||||
shares = r.json()["shares"]
|
||||
g = [s for s in shares if s.get("shared_with_group_id") == gid]
|
||||
assert len(g) == 1
|
||||
assert g[0]["kind"] == "group"
|
||||
assert g[0]["group_name"] == "Editors"
|
||||
|
||||
|
||||
def test_update_group_share_permission(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
gid = _insert_group()
|
||||
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"}).json()["id"]
|
||||
r = client.put(f"/api/pages/{pid}/share/{sid}", json={"permission": "edit"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["permission"] == "edit"
|
||||
|
||||
|
||||
def test_remove_group_share(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
gid = _insert_group()
|
||||
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"}).json()["id"]
|
||||
r = client.delete(f"/api/pages/{pid}/share/{sid}")
|
||||
assert r.status_code == 200, r.text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM page_shares WHERE id=?", (sid,)).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_share_unknown_group_404(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"group_id": 999999, "permission": "view"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_share_needs_target_400(client):
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"permission": "view"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_group_share_grants_effective_access(client):
|
||||
"""A member of an invited group sees the page via page_permissions mirror."""
|
||||
from app.services.permission_manager import PermissionManager
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
ws = _insert_workspace(1)
|
||||
_bind_page_workspace(pid, ws)
|
||||
gid = _insert_group(name="Readers")
|
||||
member = _insert_user("reader", "[email protected]", "Reader")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", (gid, member))
|
||||
conn.commit()
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "edit"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert PermissionManager(member).can_edit_page(pid)
|
||||
assert PermissionManager(member).can_view_page(pid)
|
||||
|
||||
|
||||
def test_group_share_update_syncs_grant(client):
|
||||
from app.services.permission_manager import PermissionManager
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
ws = _insert_workspace(1)
|
||||
_bind_page_workspace(pid, ws)
|
||||
gid = _insert_group(name="Writers")
|
||||
member = _insert_user("writer", "[email protected]", "Writer")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", (gid, member))
|
||||
conn.commit()
|
||||
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"}).json()["id"]
|
||||
assert PermissionManager(member).can_view_page(pid)
|
||||
assert not PermissionManager(member).can_edit_page(pid)
|
||||
client.put(f"/api/pages/{pid}/share/{sid}", json={"permission": "edit"})
|
||||
assert PermissionManager(member).can_edit_page(pid)
|
||||
|
||||
|
||||
def test_group_share_remove_revokes_grant(client):
|
||||
from app.services.permission_manager import PermissionManager
|
||||
_as(client)
|
||||
pid = _insert_page()
|
||||
ws = _insert_workspace(1)
|
||||
_bind_page_workspace(pid, ws)
|
||||
gid = _insert_group(name="Temp")
|
||||
member = _insert_user("temp", "[email protected]", "Temp")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", (gid, member))
|
||||
conn.commit()
|
||||
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "edit"}).json()["id"]
|
||||
assert PermissionManager(member).can_edit_page(pid)
|
||||
client.delete(f"/api/pages/{pid}/share/{sid}")
|
||||
assert not PermissionManager(member).can_view_page(pid)
|
||||
|
||||
|
||||
def _insert_workspace(owner_id, name="WS"):
|
||||
_ensure_owner()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, owner_id))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _bind_page_workspace(page_id, workspace_id):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=?, permission_type='restricted' WHERE id=?",
|
||||
(workspace_id, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_user(login, email, full_name):
|
||||
_ensure_owner()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, email, full_name, is_active) VALUES (?,?,?,1)",
|
||||
(login, email, full_name),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
@@ -25,10 +25,12 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.rate_limit_enabled = False
|
||||
settings.public_api_insecure_ok = True
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
@@ -24,10 +24,12 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.rate_limit_enabled = False
|
||||
settings.public_api_insecure_ok = True
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
@@ -26,10 +26,12 @@ def client():
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||||
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.rate_limit_enabled = False
|
||||
settings.public_api_insecure_ok = True
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
"""FlowDeck — v6.0.0 Granular permissions tests.
|
||||
|
||||
Covers the page / collection / property ACL: permission types (inherit,
|
||||
restricted, private), explicit grants (user + group), inheritance, property
|
||||
visibility, user groups and the audit log.
|
||||
"""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
# ═══════════════ helpers ═══════════════
|
||||
|
||||
|
||||
def _token(user_id, login="user", is_admin=0, full_name="User"):
|
||||
return SessionManager.create_session(
|
||||
{"id": user_id, "login": login, "full_name": full_name, "is_admin": is_admin}
|
||||
)
|
||||
|
||||
|
||||
def _as(client, user_id, login="user", is_admin=0):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login, is_admin))
|
||||
|
||||
|
||||
def _insert_user(login, email, full_name):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, email, full_name, is_active) VALUES (?,?,?,1)",
|
||||
(login, email, full_name),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_workspace(owner_id, name="WS"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, owner_id)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_page(workspace_id, title="Page", collection_id=None):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, collection_id) VALUES (?,?,?,'','blocks','Private',?)",
|
||||
("ws", workspace_id, title, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_collection(workspace_id, name="DB"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,'[]',?)",
|
||||
(name, workspace_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_property(collection_id, name="Field"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_properties (collection_id, name, prop_type, position) "
|
||||
"VALUES (?,?,'text',0)",
|
||||
(collection_id, name),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _set_page_type(page_id, ptype):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET permission_type=? WHERE id=?", (ptype, page_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _set_collection_type(collection_id, ptype):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE collections SET permission_type=? WHERE id=?", (ptype, collection_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _scope(owner_id, member_id=None):
|
||||
"""Return (workspace_id, page_id, owner_id, member_id) with a fresh setup."""
|
||||
ws = _insert_workspace(owner_id)
|
||||
page = _insert_page(ws)
|
||||
if member_id:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
|
||||
(ws, member_id),
|
||||
)
|
||||
conn.commit()
|
||||
return ws, page, owner_id, member_id
|
||||
|
||||
|
||||
# ═══════════════ page permission types ═══════════════
|
||||
|
||||
|
||||
def test_inherit_allows_workspace_viewer(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
|
||||
_as(client, member, "member")
|
||||
r = client.get(f"/board/api/pages/{page}")
|
||||
print("DEBUG body:", r.status_code, r.text)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Inherited viewer cannot edit.
|
||||
resp = client.put(f"/board/api/pages/{page}?title=Sneak")
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
def test_restricted_page_hidden_for_non_grantees(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "restricted")
|
||||
|
||||
# Owner still sees it.
|
||||
_as(client, owner, "owner")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
|
||||
# Workspace member WITHOUT a grant → hidden (404).
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 404
|
||||
assert client.put(f"/board/api/pages/{page}?title=x").status_code == 404
|
||||
|
||||
|
||||
def test_grant_viewer_unlocks_restricted_page(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "restricted")
|
||||
|
||||
_as(client, owner, "owner")
|
||||
grant = client.post(
|
||||
f"/api/v2/pages/{page}/permissions",
|
||||
json={"user_id": member, "role": "viewer"},
|
||||
)
|
||||
assert grant.status_code == 200, grant.text
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
# Viewer role cannot edit.
|
||||
assert client.put(f"/board/api/pages/{page}?title=x").status_code == 403
|
||||
|
||||
|
||||
def test_grant_editor_allows_edit_but_not_manage(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
|
||||
_as(client, owner, "owner")
|
||||
client.post(
|
||||
f"/api/v2/pages/{page}/permissions",
|
||||
json={"user_id": member, "role": "editor"},
|
||||
)
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
assert client.put(f"/board/api/pages/{page}?title=Edited").status_code == 200
|
||||
# Editor is not an owner → cannot grant more permissions.
|
||||
resp = client.post(
|
||||
f"/api/v2/pages/{page}/permissions",
|
||||
json={"user_id": owner, "role": "viewer"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_revoke_removes_access(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "restricted")
|
||||
|
||||
_as(client, owner, "owner")
|
||||
grant = client.post(
|
||||
f"/api/v2/pages/{page}/permissions",
|
||||
json={"user_id": member, "role": "viewer"},
|
||||
)
|
||||
perm_id = grant.json()["id"]
|
||||
revoke = client.delete(f"/api/v2/pages/{page}/permissions/{perm_id}")
|
||||
assert revoke.status_code == 200
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 404
|
||||
|
||||
|
||||
def test_private_page_admin_override(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "private")
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 404
|
||||
|
||||
# A global admin can always access.
|
||||
_as(client, member, "member", is_admin=1)
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
|
||||
|
||||
def test_mine_endpoint_and_batch_grant(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "restricted")
|
||||
|
||||
_as(client, owner, "owner")
|
||||
mine = client.get(f"/api/v2/pages/{page}/permissions/mine")
|
||||
assert mine.status_code == 200 and mine.json()["role"] == "owner"
|
||||
|
||||
batch = client.post(
|
||||
f"/api/v2/pages/{page}/permissions/batch",
|
||||
json={"grants": [{"user_id": member, "role": "viewer"}]},
|
||||
)
|
||||
assert batch.status_code == 200 and len(batch.json()["granted"]) == 1
|
||||
|
||||
_as(client, member, "member")
|
||||
mine = client.get(f"/api/v2/pages/{page}/permissions/mine")
|
||||
assert mine.json()["role"] == "viewer"
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
|
||||
|
||||
def test_page_permission_type_via_api(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
_, page, _, _ = _scope(owner)
|
||||
_as(client, owner, "owner")
|
||||
resp = client.post(
|
||||
f"/api/v2/pages/{page}/permission-type",
|
||||
json={"permission_type": "restricted"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["permission_type"] == "restricted"
|
||||
# Listing reflects the type.
|
||||
listing = client.get(f"/api/v2/pages/{page}/permissions").json()
|
||||
assert listing["permission_type"] == "restricted"
|
||||
|
||||
|
||||
# ═══════════════ collection permissions ═══════════════
|
||||
|
||||
|
||||
def test_restricted_collection_hidden_for_member(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
_set_collection_type(coll, "restricted")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
|
||||
(ws, member),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/db/{coll}/api").status_code == 404
|
||||
# HTML pages 404→redirect to /workspaces (main.py `not_found_handler`);
|
||||
# a restricted collection is therefore hidden, not served.
|
||||
assert client.get(f"/db/{coll}", follow_redirects=False).status_code == 302
|
||||
|
||||
# Owner sees it.
|
||||
_as(client, owner, "owner")
|
||||
assert client.get(f"/db/{coll}/api").status_code == 200
|
||||
|
||||
|
||||
def test_collection_grant_allows_view_then_edit_gate(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
_set_collection_type(coll, "restricted")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
|
||||
(ws, member))
|
||||
conn.commit()
|
||||
|
||||
_as(client, member, "member")
|
||||
# Without grant → 404.
|
||||
assert client.post(f"/db/{coll}/pages/api", json={"title": "New"}).status_code == 404
|
||||
|
||||
_as(client, owner, "owner")
|
||||
grant = client.post(
|
||||
f"/api/v2/collections/{coll}/permissions",
|
||||
json={"user_id": member, "role": "viewer"},
|
||||
)
|
||||
assert grant.status_code == 200, grant.text
|
||||
|
||||
_as(client, member, "member")
|
||||
# Viewer can read the collection…
|
||||
assert client.get(f"/db/{coll}/api").status_code == 200
|
||||
# …but cannot create pages.
|
||||
assert client.post(f"/db/{coll}/pages/api", json={"title": "New"}).status_code == 403
|
||||
# …and cannot delete the collection.
|
||||
assert client.delete(f"/db/api/{coll}").status_code == 403
|
||||
|
||||
|
||||
def test_owner_can_delete_collection(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
_as(client, owner, "owner")
|
||||
assert client.delete(f"/db/api/{coll}").status_code == 200
|
||||
|
||||
|
||||
def test_collection_editor_can_create_pages(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'editor')",
|
||||
(ws, member))
|
||||
conn.commit()
|
||||
|
||||
_as(client, member, "member")
|
||||
resp = client.post(f"/db/{coll}/pages/api", json={"title": "New"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
# ═══════════════ property visibility ═══════════════
|
||||
|
||||
|
||||
def test_property_permissions_visibility(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
other = _insert_user("other", "[email protected]", "Other")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
p_open = _insert_property(coll, "Open")
|
||||
p_secret = _insert_property(coll, "Secret")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
|
||||
(ws, other))
|
||||
conn.commit()
|
||||
|
||||
# Owner grants view on the Secret property to `other`.
|
||||
_as(client, owner, "owner")
|
||||
grant = client.post(
|
||||
f"/api/v2/collections/{coll}/properties/{p_secret}/permissions",
|
||||
json={"user_id": other, "role": "viewer"},
|
||||
)
|
||||
assert grant.status_code == 200, grant.text
|
||||
|
||||
_as(client, other, "other")
|
||||
props = client.get(f"/db/{coll}/properties/api").json()["properties"]
|
||||
ids = {p["id"] for p in props}
|
||||
# Open property (no grants) visible; Secret visible because granted.
|
||||
assert p_open in ids and p_secret in ids
|
||||
# Can view but not edit the Secret property.
|
||||
visible = client.get(f"/api/v2/collections/{coll}/properties/visible").json()
|
||||
assert p_secret in visible["visible"]
|
||||
|
||||
|
||||
def test_property_hidden_for_non_grantee(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
snoop = _insert_user("snoop", "[email protected]", "Snoop")
|
||||
other = _insert_user("other", "[email protected]", "Other")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
p_secret = _insert_property(coll, "Secret")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
for uid, role in ((snoop, "viewer"), (other, "viewer")):
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws, uid, role))
|
||||
conn.commit()
|
||||
|
||||
# Only `other` is granted — `snoop` has no grant.
|
||||
_as(client, owner, "owner")
|
||||
client.post(
|
||||
f"/api/v2/collections/{coll}/properties/{p_secret}/permissions",
|
||||
json={"user_id": other, "role": "viewer"},
|
||||
)
|
||||
|
||||
_as(client, snoop, "snoop")
|
||||
props = client.get(f"/db/{coll}/properties/api").json()["properties"]
|
||||
assert all(p["id"] != p_secret for p in props)
|
||||
visible = client.get(f"/api/v2/collections/{coll}/properties/visible").json()
|
||||
assert p_secret in visible["hidden"]
|
||||
|
||||
# Owner still sees everything.
|
||||
_as(client, owner, "owner")
|
||||
props = client.get(f"/db/{coll}/properties/api").json()["properties"]
|
||||
assert {p["id"] for p in props} == {p_secret}
|
||||
|
||||
|
||||
def test_property_grant_requires_collection_owner(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws = _insert_workspace(owner)
|
||||
coll = _insert_collection(ws)
|
||||
p = _insert_property(coll)
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'editor')",
|
||||
(ws, member))
|
||||
conn.commit()
|
||||
|
||||
_as(client, member, "member")
|
||||
resp = client.post(
|
||||
f"/api/v2/collections/{coll}/properties/{p}/permissions",
|
||||
json={"user_id": member, "role": "viewer"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ═══════════════ groups ═══════════════
|
||||
|
||||
|
||||
def test_group_grant_inherits_to_members(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "restricted")
|
||||
|
||||
_as(client, owner, "owner")
|
||||
gid = client.post(
|
||||
"/api/v2/groups", json={"name": "Editors", "workspace_id": ws}
|
||||
).json()["id"]
|
||||
assert client.post(f"/api/v2/groups/{gid}/members", json={"user_id": member}).status_code == 200
|
||||
assert client.post(
|
||||
f"/api/v2/pages/{page}/permissions",
|
||||
json={"group_id": gid, "role": "viewer"},
|
||||
).status_code == 200
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
|
||||
# Listing shows the group grant on the page.
|
||||
_as(client, owner, "owner")
|
||||
grants = client.get(f"/api/v2/pages/{page}/permissions").json()["permissions"]
|
||||
assert any(g["kind"] == "group" and g["name"] == "Editors" for g in grants)
|
||||
|
||||
|
||||
def test_group_member_removal_revokes_group_access(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws, page, _, _ = _scope(owner, member)
|
||||
_set_page_type(page, "restricted")
|
||||
|
||||
_as(client, owner, "owner")
|
||||
gid = client.post(
|
||||
"/api/v2/groups", json={"name": "G", "workspace_id": ws}
|
||||
).json()["id"]
|
||||
client.post(f"/api/v2/groups/{gid}/members", json={"user_id": member})
|
||||
client.post(f"/api/v2/pages/{page}/permissions", json={"group_id": gid, "role": "viewer"})
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 200
|
||||
|
||||
_as(client, owner, "owner")
|
||||
assert client.delete(f"/api/v2/groups/{gid}/members/{member}").status_code == 200
|
||||
|
||||
_as(client, member, "member")
|
||||
assert client.get(f"/board/api/pages/{page}").status_code == 404
|
||||
|
||||
|
||||
def test_groups_listing_and_members(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
ws = _insert_workspace(owner)
|
||||
_as(client, owner, "owner")
|
||||
gid = client.post(
|
||||
"/api/v2/groups", json={"name": "Team", "workspace_id": ws}
|
||||
).json()["id"]
|
||||
client.post(f"/api/v2/groups/{gid}/members", json={"user_id": member})
|
||||
|
||||
groups = client.get(f"/api/v2/groups?workspace_id={ws}").json()["groups"]
|
||||
assert len(groups) == 1 and groups[0]["member_count"] == 1
|
||||
members = client.get(f"/api/v2/groups/{gid}/members").json()["members"]
|
||||
assert members[0]["id"] == member
|
||||
|
||||
# Non-owner cannot add members.
|
||||
_as(client, member, "member")
|
||||
resp = client.post(f"/api/v2/groups/{gid}/members", json={"user_id": owner})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ═══════════════ audit log + auth ═══════════════
|
||||
|
||||
|
||||
def test_audit_log_records_changes(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
member = _insert_user("member", "[email protected]", "Member")
|
||||
_, page, _, _ = _scope(owner, member)
|
||||
|
||||
_as(client, owner, "owner")
|
||||
client.post(f"/api/v2/pages/{page}/permissions", json={"user_id": member, "role": "viewer"})
|
||||
client.post(f"/api/v2/pages/{page}/permission-type", json={"permission_type": "restricted"})
|
||||
|
||||
events = client.get("/api/v2/audit/permissions").json()["events"]
|
||||
actions = [e["action"] for e in events]
|
||||
assert "grant" in actions and "type_change" in actions
|
||||
# Only the owner workspace can view the log.
|
||||
_as(client, member, "member")
|
||||
assert client.get("/api/v2/audit/permissions").status_code == 403
|
||||
|
||||
|
||||
def test_permissions_endpoints_require_auth(client):
|
||||
assert client.get("/api/v2/pages/1/permissions").status_code == 401
|
||||
assert client.post("/api/v2/pages/1/permissions", json={"user_id": 2, "role": "viewer"}).status_code == 401
|
||||
assert client.get("/api/v2/groups").status_code == 401
|
||||
assert client.get("/api/v2/audit/permissions").status_code == 401
|
||||
|
||||
|
||||
def test_grant_requires_valid_user_or_group(client):
|
||||
owner = _insert_user("owner", "[email protected]", "Owner")
|
||||
_, page, _, _ = _scope(owner)
|
||||
_as(client, owner, "owner")
|
||||
|
||||
# Unknown target user.
|
||||
resp = client.post(f"/api/v2/pages/{page}/permissions", json={"user_id": 99999, "role": "viewer"})
|
||||
assert resp.status_code == 404
|
||||
# Unknown group.
|
||||
resp = client.post(f"/api/v2/pages/{page}/permissions", json={"group_id": 99999, "role": "viewer"})
|
||||
assert resp.status_code == 404
|
||||
# Invalid role.
|
||||
resp = client.post(f"/api/v2/pages/{page}/permissions", json={"user_id": owner, "role": "boss"})
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,491 @@
|
||||
"""FlowDeck — v6.5.0 Synced blocks production (databases & vues).
|
||||
|
||||
Covers:
|
||||
- migration 22 (``pages.collection_row_id``) + index;
|
||||
- database row content pages: lazy creation, idempotence, cascade delete,
|
||||
title sync row ↔ page;
|
||||
- sidebar/tree/workspace listings exclude content pages;
|
||||
- server-side resolution of synced blocks on every read path
|
||||
(content API, public page) — stale cache is replaced at read time;
|
||||
- propagation: updating/deleting a source REWRITES every referencing
|
||||
page's stored content (was dead code), recursively (columns/toggles),
|
||||
including content pages of database rows;
|
||||
- API v2 exposes ``content_page_id`` for rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.synced_blocks import (
|
||||
create_synced_block,
|
||||
get_page_synced,
|
||||
get_synced_block,
|
||||
update_synced_block,
|
||||
)
|
||||
|
||||
# ── Helpers (patterns from test_v514 / test_v57) ─────────────────
|
||||
|
||||
def _login(client):
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
login = f"v65_{secrets.token_hex(4)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_admin) "
|
||||
"VALUES (?, 'V65', ?, 0)",
|
||||
(login, f"{login}@test.com"),
|
||||
)
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
session = SessionManager.create_session({"id": uid, "login": login})
|
||||
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
||||
return session, csrf, uid
|
||||
|
||||
|
||||
def _hdr(csrf):
|
||||
return {"X-CSRF-Token": csrf}
|
||||
|
||||
|
||||
def _create_page(client, session, csrf, title="Page"):
|
||||
r = client.post(
|
||||
f"/board/api/pages?title={title}",
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _add_collection(client, name="DB"):
|
||||
r = client.post("/db/api", json={"name": name})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _add_row(client, cid, title="Row A"):
|
||||
r = client.post(f"/db/{cid}/pages/api", json={"title": title})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _raw_page_content(page_id):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format, title, parent_section, "
|
||||
"collection_row_id, deleted_at FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Migration ────────────────────────────────────────────────────
|
||||
|
||||
def test_migration_v65_column_and_index(client):
|
||||
with get_conn() as conn:
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
idx = {r[1] for r in conn.execute("PRAGMA index_list(pages)").fetchall()}
|
||||
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
||||
assert "collection_row_id" in cols
|
||||
assert "idx_pages_row" in idx
|
||||
assert version >= 22
|
||||
|
||||
|
||||
# ── Row content pages ────────────────────────────────────────────
|
||||
|
||||
def test_open_row_creates_content_page(client):
|
||||
session, csrf, uid = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "My row")
|
||||
|
||||
r1 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
|
||||
assert r1.status_code == 200, r1.text
|
||||
pid1 = r1.json()["page_id"]
|
||||
assert pid1 > 0
|
||||
|
||||
page = _raw_page_content(pid1)
|
||||
assert page is not None
|
||||
assert page["collection_row_id"] == rid
|
||||
assert page["title"] == "My row"
|
||||
assert page["parent_section"] == "DbRow"
|
||||
assert page["content_format"] == "blocks"
|
||||
|
||||
# Idempotent: second open returns the same page.
|
||||
r2 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
|
||||
assert r2.json()["page_id"] == pid1
|
||||
|
||||
|
||||
def test_open_unknown_row_404(client):
|
||||
session, _, _ = _login(client)
|
||||
r = client.get("/db/pages/999999/open/api", cookies={"flowdeck_session": session})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_row_title_sync_to_content_page(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Original")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
r = client.put(
|
||||
f"/db/pages/{rid}/api",
|
||||
json={"title": "Renamed row"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _raw_page_content(pid)["title"] == "Renamed row"
|
||||
|
||||
|
||||
def test_content_page_title_sync_to_row(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Before")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
r = client.put(
|
||||
f"/api/pages/{pid}/rename",
|
||||
json={"title": "Renamed in editor"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()
|
||||
assert row["title"] == "Renamed in editor"
|
||||
|
||||
|
||||
def test_row_delete_cascades_content_page(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Doomed")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
r = client.delete(
|
||||
f"/db/pages/{rid}/api",
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _raw_page_content(pid) is None
|
||||
|
||||
|
||||
def test_content_page_restored_from_trash(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Trashed")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
# Simulate the user trashing the content page from the editor menu.
|
||||
client.post(f"/api/pages/{pid}/trash", headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session})
|
||||
assert _raw_page_content(pid)["deleted_at"]
|
||||
|
||||
r = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
|
||||
assert r.json()["page_id"] == pid
|
||||
page = _raw_page_content(pid)
|
||||
assert page["deleted_at"] is None
|
||||
assert page["parent_section"] == "DbRow"
|
||||
|
||||
|
||||
# ── Listings exclusion ───────────────────────────────────────────
|
||||
|
||||
def test_sidebar_tree_excludes_content_pages(client):
|
||||
session, _, uid = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Shadow row")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"]
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, "
|
||||
"content_format, parent_section) VALUES ('ws/tree', NULL, "
|
||||
"'Visible page', '', 'blocks', 'Private')"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace='ws/tree' WHERE id=? OR id=?",
|
||||
(pid, pid),
|
||||
)
|
||||
conn.commit()
|
||||
from app.routers.board import _build_page_tree
|
||||
items = _build_page_tree(conn, None, "ws/tree")
|
||||
titles = {i["name"] for i in items}
|
||||
assert "Visible page" in titles
|
||||
assert "Shadow row" not in titles
|
||||
|
||||
|
||||
def test_workspace_tree_excludes_content_pages(client):
|
||||
from app.routers.dashboard import _build_tree_children
|
||||
|
||||
session, csrf, uid = _login(client)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES ('WS65', ?)", (uid,)
|
||||
)
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, "
|
||||
"content_format, parent_section) VALUES ('', ?, 'Visible', '', "
|
||||
"'blocks', 'Workspace')",
|
||||
(ws_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# a content page living in the same workspace (link preserved →
|
||||
# the IS NULL filter is what must hide it)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Hidden row")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=? WHERE collection_row_id=?",
|
||||
(ws_id, rid),
|
||||
)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT title FROM pages WHERE parent_id IS NULL AND workspace_id=? "
|
||||
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = _build_tree_children(conn, None, ws_id, uid)
|
||||
titles = {r["title"] for r in rows}
|
||||
item_titles = {i.get("title") or i.get("name") for i in items}
|
||||
assert "Visible" in titles
|
||||
assert "Hidden row" not in titles
|
||||
assert "Hidden row" not in item_titles
|
||||
|
||||
|
||||
# ── Server-side resolution on read ───────────────────────────────
|
||||
|
||||
def _page_with_stale_synced(client, session, csrf, uid):
|
||||
"""Page whose stored synced cache is stale (source updated offline)."""
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Src",
|
||||
content=[{"type": "paragraph", "content": "VERSION_ONE"}],
|
||||
created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Reader")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={
|
||||
"blocks": [
|
||||
{"type": "paragraph", "content": "intro", "id": "b1"},
|
||||
{"type": "synced", "synced_id": sid, "id": "b2",
|
||||
"_synced_content": [{"type": "paragraph", "content": "VERSION_ONE"}],
|
||||
"_synced_title": "Src"},
|
||||
],
|
||||
"title": "Reader",
|
||||
},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# Source changes WITHOUT going through the API (worst case: stale cache).
|
||||
update_synced_block(sid, "Src", [{"type": "paragraph", "content": "VERSION_TWO"}])
|
||||
return sid, page_id
|
||||
|
||||
|
||||
def test_content_api_resolves_stale_synced_cache(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
|
||||
|
||||
# stored content is still stale…
|
||||
assert "VERSION_ONE" in _raw_page_content(page_id)["content"]
|
||||
|
||||
r = client.get(f"/api/pages/{page_id}/content")
|
||||
assert r.status_code == 200
|
||||
blocks = json.loads(r.json()["content"])
|
||||
synced = [b for b in blocks if b.get("type") == "synced"][0]
|
||||
assert synced["_synced_content"] == [{"type": "paragraph", "content": "VERSION_TWO"}]
|
||||
|
||||
|
||||
def test_editor_render_resolves_stale_synced_cache(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
|
||||
|
||||
r = client.get(f"/pages/{page_id}", cookies={"flowdeck_session": session})
|
||||
assert r.status_code == 200
|
||||
# page_data JSON embedded in the editor contains the fresh content
|
||||
assert "VERSION_TWO" in r.text
|
||||
|
||||
|
||||
def test_public_page_resolves_and_renders_synced(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, published=1, publish_slug='v65pub' "
|
||||
"WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
r = client.get("/p/v65pub")
|
||||
assert r.status_code == 200
|
||||
assert "VERSION_TWO" in r.text
|
||||
# no raw block JSON leaked into the public HTML
|
||||
assert '{"type"' not in r.text
|
||||
|
||||
|
||||
# ── Propagation rewrites stored content ──────────────────────────
|
||||
|
||||
def test_put_synced_rewrites_referencing_pages(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Src",
|
||||
content=[{"type": "paragraph", "content": "V1"}], created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Ref page")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "V1"}]}],
|
||||
"title": "Ref page"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.put(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
json={"title": "Src", "content": [{"type": "paragraph", "content": "V3"}]},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The page's STORED content was rewritten (not just resolved at read).
|
||||
raw = _raw_page_content(page_id)["content"]
|
||||
assert "V3" in raw
|
||||
assert "V1" not in raw
|
||||
|
||||
|
||||
def test_propagation_recurses_into_children(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Nested",
|
||||
content=[{"type": "paragraph", "content": "OLD"}], created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Columns page")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"blocks": [
|
||||
{"type": "columns", "id": "cols", "children": [
|
||||
{"type": "column", "id": "c1", "children": [
|
||||
{"type": "synced", "synced_id": sid, "id": "n1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "OLD"}]},
|
||||
]},
|
||||
]},
|
||||
], "title": "Columns page"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert get_page_synced(page_id), "nested synced block must be tracked"
|
||||
|
||||
r = client.put(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
json={"title": "Nested", "content": [{"type": "paragraph", "content": "NEW"}]},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
raw = _raw_page_content(page_id)["content"]
|
||||
assert "NEW" in raw
|
||||
assert "OLD" not in raw
|
||||
|
||||
|
||||
def test_delete_source_marks_instances_deleted(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Doomed",
|
||||
content=[{"type": "paragraph", "content": "X"}], created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Victim")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "X"}]}],
|
||||
"title": "Victim"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.delete(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert get_synced_block(sid) is None
|
||||
assert get_page_synced(page_id) == [] # refs cascaded away
|
||||
|
||||
raw = json.loads(_raw_page_content(page_id)["content"])
|
||||
synced = [b for b in raw if b.get("type") == "synced"][0]
|
||||
assert synced.get("_synced_deleted") is True
|
||||
assert "_synced_content" not in synced
|
||||
|
||||
# read path agrees
|
||||
r = client.get(f"/api/pages/{page_id}/content")
|
||||
blocks = json.loads(r.json()["content"])
|
||||
assert [b for b in blocks if b.get("type") == "synced"][0].get("_synced_deleted") is True
|
||||
|
||||
|
||||
# ── Synced blocks inside database rows (the v6 point) ────────────
|
||||
|
||||
def test_synced_block_lives_and_syncs_inside_a_db_row(client):
|
||||
session, csrf, uid = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Row with content")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Row synced",
|
||||
content=[{"type": "paragraph", "content": "ROW_V1"}], created_by=uid,
|
||||
)
|
||||
r = client.post(
|
||||
f"/board/api/pages/{pid}/blocks",
|
||||
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "ROW_V1"}]}],
|
||||
"title": "Row with content"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert any(x["synced_block_id"] == sid for x in get_page_synced(pid))
|
||||
# saving blocks with a title synced the row title too
|
||||
with get_conn() as conn:
|
||||
assert conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()["title"] == "Row with content"
|
||||
|
||||
# update the source → the ROW's content page is rewritten
|
||||
r = client.put(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
json={"title": "Row synced", "content": [{"type": "paragraph", "content": "ROW_V2"}]},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "ROW_V2" in _raw_page_content(pid)["content"]
|
||||
|
||||
|
||||
def test_api_v2_row_exposes_content_page_id(client):
|
||||
r = client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "v65api",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
tok = client.post("/api/v1/token").json()["token"]
|
||||
headers = {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Api row")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"]
|
||||
|
||||
r = client.get(f"/api/v2/pages/{rid}", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("content_page_id") == pid
|
||||
|
||||
# row without content page → null (no lazy creation on read)
|
||||
rid2 = _add_row(client, cid, "No content yet")
|
||||
r = client.get(f"/api/v2/pages/{rid2}", headers=headers)
|
||||
assert r.json().get("content_page_id") is None
|
||||
@@ -0,0 +1,296 @@
|
||||
"""FlowDeck — v6.2.0 Web Clipper tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
|
||||
def _token(user_id, login="user", is_admin=0):
|
||||
return SessionManager.create_session({"id": user_id, "login": login, "full_name": "User", "is_admin": is_admin})
|
||||
|
||||
|
||||
def _as(client, user_id, login="user", is_admin=0):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login, is_admin))
|
||||
|
||||
|
||||
def _insert_user(login="clipper_user", email="[email protected]"):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, email, full_name, is_active) VALUES (?,?,?,1)",
|
||||
(login, email, "Clipper User"),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_workspace(owner_id, name="ClipWS"):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, owner_id))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?, 'admin')", (cur.lastrowid, owner_id))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── Service unit tests ──
|
||||
|
||||
def test_sanitize_removes_script(client):
|
||||
from app.services.web_clipper import sanitize_html
|
||||
|
||||
html = '<p>Hello</p><script>alert(1)</script><div onclick="evil()">x</div><a href="javascript:alert(1)">click</a>'
|
||||
clean = sanitize_html(html)
|
||||
assert "<script" not in clean
|
||||
assert "onclick" not in clean
|
||||
assert "javascript:" not in clean
|
||||
assert "Hello" in clean
|
||||
|
||||
|
||||
def test_html_to_blocks_basic(client):
|
||||
from app.services.web_clipper import html_to_blocks
|
||||
|
||||
html = "<h1>Title</h1><p>Paragraph text</p><ul><li>Item A</li><li>Item B</li></ul><blockquote>Quote</blockquote>"
|
||||
blocks = html_to_blocks(html)
|
||||
types = [b["type"] for b in blocks]
|
||||
assert "heading_1" in types
|
||||
assert "paragraph" in types
|
||||
assert "bulleted_list" in types
|
||||
assert "quote" in types
|
||||
|
||||
|
||||
def test_html_to_blocks_image(client):
|
||||
from app.services.web_clipper import html_to_blocks
|
||||
|
||||
html = '<p><img src="https://example.com/img.png" alt="alt"></p><p>Text</p>'
|
||||
blocks = html_to_blocks(html)
|
||||
assert any(b["type"] == "image" and b["src"] == "https://example.com/img.png" for b in blocks)
|
||||
|
||||
|
||||
def test_extract_article(client):
|
||||
from app.services.web_clipper import extract_article
|
||||
|
||||
html = "<html><head><title> My Article </title></head><body><article><h1>Heading</h1><p>Body text</p></article></body></html>"
|
||||
res = extract_article(html, url="https://example.com/a")
|
||||
assert res["title"] == "My Article"
|
||||
assert any(b["type"] == "heading_1" for b in res["blocks"])
|
||||
|
||||
|
||||
# ── API tests ──
|
||||
|
||||
def test_clip_requires_auth(client):
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "T"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_clip_article_creates_page(client):
|
||||
uid = _insert_user("clip_a")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_a")
|
||||
html = "<html><head><title>Example Article</title></head><body><h1>Hello</h1><p>World paragraph</p><p><img src='https://example.com/x.png' alt='x'></p></body></html>"
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/article", "title": "Example Article", "content": html, "content_type": "article", "device_id": "dev1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["page_id"]
|
||||
# Verify page in DB
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT title, content, workspace_id FROM pages WHERE id=?", (data["page_id"],)).fetchone()
|
||||
assert row is not None
|
||||
assert row["title"] == "Example Article"
|
||||
blocks = json.loads(row["content"])
|
||||
assert any(b["type"] == "paragraph" for b in blocks)
|
||||
# Clip logged
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (uid,)).fetchone()[0]
|
||||
assert cnt == 1
|
||||
|
||||
|
||||
def test_clip_bookmark(client):
|
||||
uid = _insert_user("clip_b")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_b")
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com", "title": "My Bookmark", "content_type": "bookmark", "device_id": "dev-b"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (data["page_id"],)).fetchone()
|
||||
blocks = json.loads(row["content"])
|
||||
assert any(b["type"] == "bookmark" and b["url"] == "https://example.com" for b in blocks)
|
||||
|
||||
|
||||
def test_clip_selection(client):
|
||||
uid = _insert_user("clip_sel")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_sel")
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={
|
||||
"url": "https://example.com/p",
|
||||
"title": "Sel Test",
|
||||
"content_type": "selection",
|
||||
"selection_html": "<p>Selected <b>text</b> here</p>",
|
||||
"device_id": "dev-sel",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
blocks = json.loads(row["content"])
|
||||
assert any("Selected" in b.get("content", "") for b in blocks)
|
||||
|
||||
|
||||
def test_clip_screenshot(client):
|
||||
uid = _insert_user("clip_shot")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_shot")
|
||||
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com", "title": "Shot", "content_type": "screenshot", "image_base64": b64, "device_id": "dev-shot"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
blocks = json.loads(row["content"])
|
||||
assert any(b["type"] == "image" for b in blocks)
|
||||
|
||||
|
||||
def test_clip_sanitization_script_stripped(client):
|
||||
uid = _insert_user("clip_sani")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_sani")
|
||||
html = '<p>ok</p><script>alert(1)</script>'
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com", "content": html, "content_type": "article", "device_id": "dev-sani"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
assert "alert" not in row["content"]
|
||||
assert "ok" in row["content"]
|
||||
|
||||
|
||||
def test_clip_rate_limit(client):
|
||||
uid = _insert_user("clip_rate")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_rate")
|
||||
# Reset rate store bucket for isolation (uses in-memory dict keyed by user:device)
|
||||
from app.services.web_clipper import _rate_store
|
||||
|
||||
_rate_store.clear()
|
||||
for i in range(50):
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": f"https://example.com/{i}", "title": f"T{i}", "content": "<p>hi</p>", "device_id": "rate-dev"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/overflow", "title": "overflow", "content": "<p>hi</p>", "device_id": "rate-dev"},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
_rate_store.clear()
|
||||
|
||||
|
||||
def test_clip_bearer_token_auth(client):
|
||||
import hashlib
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
uid = _insert_user("clip_bearer")
|
||||
_insert_workspace(uid)
|
||||
# Create api_token
|
||||
token = "fd_test_clipper_bearer_123"
|
||||
th = hashlib.sha256(token.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?,?,?,?)", (uid, "test", th, token[:12]))
|
||||
conn.commit()
|
||||
# No session cookie
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/bearer", "title": "BearerClip", "content": "<p>via bearer</p>", "device_id": "bearer-dev"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
assert row["title"] == "BearerClip"
|
||||
|
||||
|
||||
def test_status_and_devices_flow(client):
|
||||
uid = _insert_user("clip_status")
|
||||
_insert_workspace(uid)
|
||||
# unauth status
|
||||
client.cookies.clear()
|
||||
r = client.get("/api/v2/web-clipper/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["authenticated"] is False
|
||||
# auth status
|
||||
_as(client, uid, "clip_status")
|
||||
r = client.get("/api/v2/web-clipper/status")
|
||||
assert r.json()["authenticated"] is True
|
||||
# register device via verify
|
||||
r = client.post("/api/v2/web-clipper/auth/verify", json={"device_id": "dev-xyz-123", "device_name": "Chrome — Test", "extension_name": "chrome"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["device_id"] == "dev-xyz-123"
|
||||
# second verify same device reports existing
|
||||
r2 = client.post("/api/v2/web-clipper/auth/verify", json={"device_id": "dev-xyz-123", "device_name": "Chrome — Test"})
|
||||
assert r2.json().get("existing") is True
|
||||
# list devices
|
||||
r = client.get("/api/v2/web-clipper/devices")
|
||||
assert r.status_code == 200
|
||||
devs = r.json()["devices"]
|
||||
assert any(d["device_id"] == "dev-xyz-123" for d in devs)
|
||||
did = [d for d in devs if d["device_id"] == "dev-xyz-123"][0]["id"]
|
||||
# revoke
|
||||
r = client.delete(f"/api/v2/web-clipper/devices/{did}")
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/v2/web-clipper/devices")
|
||||
remaining = [d for d in r.json()["devices"] if d["id"] == did]
|
||||
assert remaining[0]["revoked"] == 1
|
||||
|
||||
|
||||
def test_extensions_page(client):
|
||||
r = client.get("/extensions")
|
||||
assert r.status_code == 200
|
||||
assert "Web Clipper" in r.text
|
||||
assert "Chrome" in r.text
|
||||
|
||||
|
||||
def test_clip_invalid_url_rejected(client):
|
||||
uid = _insert_user("clip_badurl")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_badurl")
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "javascript:alert(1)", "title": "x", "device_id": "d"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_clip_payload_too_large(client):
|
||||
uid = _insert_user("clip_big")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_big")
|
||||
big = "x" * (11 * 1024 * 1024) # 11 MB string
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "big", "content": big, "device_id": "big-dev"})
|
||||
# Expect 413 or 400 due to body size / content-length
|
||||
assert r.status_code in (413, 400, 422)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.webhook_outbound import (
|
||||
EVENTS,
|
||||
MAX_ATTEMPTS,
|
||||
RETRY_DELAYS,
|
||||
_deliver_once,
|
||||
_event_matches,
|
||||
init_webhook_tables,
|
||||
sign_payload,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
"""Ensure webhook tables exist."""
|
||||
init_webhook_tables()
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_payload():
|
||||
return {"page_id": 123, "title": "Test Page", "user_id": 456}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_secret():
|
||||
return "test_webhook_secret_12345"
|
||||
|
||||
|
||||
# ── HMAC Signature Tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sign_payload_returns_sha256_hex():
|
||||
"""Test that sign_payload returns a valid HMAC-SHA256 signature."""
|
||||
secret = "my_secret"
|
||||
body = b'{"event": "page.created", "data": {}}'
|
||||
signature = sign_payload(secret, body)
|
||||
|
||||
assert signature.startswith("sha256=")
|
||||
hex_part = signature[7:] # Remove 'sha256=' prefix
|
||||
assert len(hex_part) == 64 # SHA-256 hex is 64 chars
|
||||
|
||||
|
||||
def test_sign_payload_consistency():
|
||||
"""Test that the same input produces the same signature."""
|
||||
secret = "my_secret"
|
||||
body = b'{"event": "page.created"}'
|
||||
|
||||
sig1 = sign_payload(secret, body)
|
||||
sig2 = sign_payload(secret, body)
|
||||
|
||||
assert sig1 == sig2
|
||||
|
||||
|
||||
def test_verify_signature_valid():
|
||||
"""Test that verify_signature returns True for valid signatures."""
|
||||
secret = "my_secret"
|
||||
body = b'{"event": "page.created", "data": {}}'
|
||||
signature = sign_payload(secret, body)
|
||||
|
||||
assert verify_signature(secret, body, signature) is True
|
||||
|
||||
|
||||
def test_verify_signature_invalid_secret():
|
||||
"""Test that verify_signature returns False for wrong secret."""
|
||||
secret = "my_secret"
|
||||
wrong_secret = "wrong_secret"
|
||||
body = b'{"event": "page.created"}'
|
||||
signature = sign_payload(secret, body)
|
||||
|
||||
assert verify_signature(wrong_secret, body, signature) is False
|
||||
|
||||
|
||||
def test_verify_signature_invalid_signature():
|
||||
"""Test that verify_signature returns False for tampered signature."""
|
||||
secret = "my_secret"
|
||||
body = b'{"event": "page.created"}'
|
||||
signature = "sha256=invalid_hex_value"
|
||||
|
||||
assert verify_signature(secret, body, signature) is False
|
||||
|
||||
|
||||
def test_verify_signature_empty_secret():
|
||||
"""Test that verify_signature returns False for empty secret."""
|
||||
body = b'{"event": "page.created"}'
|
||||
signature = "sha256=somehash"
|
||||
|
||||
assert verify_signature("", body, signature) is False
|
||||
|
||||
|
||||
def test_verify_signature_empty_signature():
|
||||
"""Test that verify_signature returns False for empty signature."""
|
||||
secret = "my_secret"
|
||||
body = b'{"event": "page.created"}'
|
||||
|
||||
assert verify_signature(secret, body, "") is False
|
||||
|
||||
|
||||
def test_verify_signature_none_values():
|
||||
"""Test that verify_signature handles None values."""
|
||||
assert verify_signature(None, b'{}', "sha256=abc") is False
|
||||
assert verify_signature("secret", None, "sha256=abc") is False
|
||||
assert verify_signature("secret", b'{}', None) is False
|
||||
|
||||
|
||||
# ── Event Matching Tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_event_matches_exact():
|
||||
"""Test exact event matching."""
|
||||
assert _event_matches("page.created", "page.created") is True
|
||||
assert _event_matches("page.created", "page.updated") is False
|
||||
|
||||
|
||||
def test_event_matches_wildcard_all():
|
||||
"""Test wildcard '*' matches all events."""
|
||||
assert _event_matches("*", "page.created") is True
|
||||
assert _event_matches("*", "collection.deleted") is True
|
||||
assert _event_matches("all", "agent.run.finished") is True
|
||||
|
||||
|
||||
def test_event_matches_wildcard_prefix():
|
||||
"""Test wildcard prefix matching (e.g., 'page.*')."""
|
||||
assert _event_matches("page.*", "page.created") is True
|
||||
assert _event_matches("page.*", "page.updated") is True
|
||||
assert _event_matches("page.*", "page.deleted") is True
|
||||
assert _event_matches("page.*", "collection.created") is False
|
||||
|
||||
|
||||
def test_event_matches_wildcard_suffix():
|
||||
"""Test that suffix wildcards are not supported (only prefix)."""
|
||||
assert _event_matches("*.created", "page.created") is False
|
||||
assert _event_matches("*.created", "collection.created") is False
|
||||
|
||||
|
||||
# ── Event Catalogue Tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_events_list_has_50_plus_events():
|
||||
"""Test that EVENTS list has at least 50 events."""
|
||||
assert len(EVENTS) >= 50, f"Expected at least 50 events, got {len(EVENTS)}"
|
||||
|
||||
|
||||
def test_events_list_contains_new_events():
|
||||
"""Test that new events are included in the catalogue."""
|
||||
new_events = [
|
||||
"page.renamed", "page.duplicated", "page.archived",
|
||||
"comment.updated", "comment.deleted",
|
||||
"mention.resolved",
|
||||
"collection.renamed", "collection.duplicated",
|
||||
"collection.page.moved",
|
||||
"collection.view.updated", "collection.view.deleted",
|
||||
"collection.property.created", "collection.property.updated", "collection.property.deleted",
|
||||
"sprint.deleted", "sprint.started", "sprint.completed", "sprint.canceled",
|
||||
"share.created", "share.updated", "share.revoked",
|
||||
"automation.failed", "automation.retrying",
|
||||
"agent.run.started", "agent.run.failed",
|
||||
"workspace.created", "workspace.updated", "workspace.deleted",
|
||||
"workspace.member.added", "workspace.member.removed", "workspace.member.role_changed",
|
||||
"file.uploaded", "file.deleted",
|
||||
"import.started", "import.completed", "import.failed",
|
||||
]
|
||||
for event in new_events:
|
||||
assert event in EVENTS, f"Event '{event}' not found in EVENTS list"
|
||||
|
||||
|
||||
def test_events_list_contains_legacy_events():
|
||||
"""Test that legacy events are still present."""
|
||||
legacy_events = [
|
||||
"page.created", "page.updated", "page.deleted", "page.moved",
|
||||
"comment.added", "comment.resolved",
|
||||
"collection.created", "collection.updated", "collection.deleted",
|
||||
"sprint.created", "sprint.updated",
|
||||
"favorite.added", "favorite.removed",
|
||||
"automation.fired", "agent.run.finished",
|
||||
"ping",
|
||||
]
|
||||
for event in legacy_events:
|
||||
assert event in EVENTS, f"Legacy event '{event}' missing from EVENTS list"
|
||||
|
||||
|
||||
# ── Retry Schedule Tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_retry_delays_are_2_10_60():
|
||||
"""Test that retry delays are 2s, 10s, 60s."""
|
||||
assert RETRY_DELAYS == (2, 10, 60)
|
||||
|
||||
|
||||
def test_max_attempts_is_4():
|
||||
"""Test that MAX_ATTEMPTS is 4 (1 initial + 3 retries)."""
|
||||
assert MAX_ATTEMPTS == 4
|
||||
|
||||
|
||||
# ── Delivery Tests (Mocked) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_once_success():
|
||||
"""Test successful delivery in _deliver_once."""
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
url = "https://example.com/webhook"
|
||||
event = "page.created"
|
||||
payload = {"page_id": 123}
|
||||
secret = "test_secret"
|
||||
|
||||
code, error = await _deliver_once(mock_client, url, event, payload, secret)
|
||||
|
||||
assert code == 200
|
||||
assert error == ""
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args.kwargs["headers"]["X-FlowDeck-Event"] == event
|
||||
assert call_args.kwargs["headers"]["X-FlowDeck-Signature"].startswith("sha256=")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_once_http_error():
|
||||
"""Test _deliver_once handles HTTP errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
url = "https://example.com/webhook"
|
||||
event = "page.created"
|
||||
payload = {}
|
||||
secret = "test_secret"
|
||||
|
||||
code, error = await _deliver_once(mock_client, url, event, payload, secret)
|
||||
|
||||
assert code == 500
|
||||
assert "HTTP 500" in error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_once_timeout():
|
||||
"""Test _deliver_once handles timeout errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = httpx.TimeoutException("Timeout after 30s")
|
||||
|
||||
url = "https://example.com/webhook"
|
||||
event = "page.created"
|
||||
payload = {}
|
||||
secret = "test_secret"
|
||||
|
||||
code, error = await _deliver_once(mock_client, url, event, payload, secret)
|
||||
|
||||
assert code is None
|
||||
assert "Timeout" in error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_once_request_error():
|
||||
"""Test _deliver_once handles request errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = httpx.RequestError("Connection refused")
|
||||
|
||||
url = "https://example.com/webhook"
|
||||
event = "page.created"
|
||||
payload = {}
|
||||
secret = "test_secret"
|
||||
|
||||
code, error = await _deliver_once(mock_client, url, event, payload, secret)
|
||||
|
||||
assert code is None
|
||||
assert "Request error" in error
|
||||
|
||||
|
||||
# ── Integration Tests (Skipped if dependencies are missing) ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_to_sub_retries_on_failure():
|
||||
"""Test that deliver_to_sub retries on failure."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_to_sub_fails_after_max_attempts():
|
||||
"""Test that deliver_to_sub fails after MAX_ATTEMPTS."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_event_ignores_unknown_events():
|
||||
"""Test that fire_event ignores unknown events."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_event_no_subscribers():
|
||||
"""Test that fire_event does nothing if no subscribers."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_event_delivers_to_subscribers():
|
||||
"""Test that fire_event delivers to matching subscribers."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_webhook_flow():
|
||||
"""Test a full webhook flow: event fired → delivered to subscriber."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_due_deliveries():
|
||||
"""Test retry_due_deliveries retries failed deliveries."""
|
||||
pass
|
||||