feat: v6.3.0 API publique complete v2 (REST /api/v2, scopes, OpenAPI)
FlowDeck CI / lint (push) Successful in 1m13s
FlowDeck CI / test (push) Successful in 9m20s
FlowDeck CI / docker (push) Successful in 1m10s

- Router api_v2.py (~100 endpoints) : tokens, users, workspaces/members,
  collections, pages, proprietes, vues/dashboards, commentaires/mentions,
  notifications, favoris/tags/recents, partage/publish, historique, sprints,
  templates, export/import, forges, recherche FTS, admin, webhooks CRUD
- Helpers api_v2_helpers.py : Bearer unifie (sha256/expires_at/extension_devices),
  scopes hierarchiques read<write<admin, pagination + X-Total-Count, ISO-8601,
  RFC 7807, idempotence, audit, rate-limit par token
- Migration 20 : api_tokens.scopes/expires_at, webhook_deliveries,
  api_audit_log, idempotency_keys
- main.py : handler d'erreurs unifie StarletteHTTPException, /docs + /redoc
- config : PUBLIC_API_INSECURE_OK (dev only), API_V2_RATE_LIMIT_PER_TOKEN
- OpenAPI docs/openapi-v2.json (402 chemins), tests/test_public_api_v2.py (24)
- Docs : CHANGELOG (v6.2.0/6.2.1 clipper + v6.3.0), ROADMAP, API_GUIDE_V6,
  V6_Web_Clipper, README, ARCHITECTURE, /help
- Suite complete 668 verte, ruff OK
This commit is contained in:
2026-09-20 13:19:29 -04:00
parent ea19d1d050
commit 95bc861cdb
25 changed files with 25078 additions and 66 deletions
+5
View File
@@ -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=
+6 -3
View File
@@ -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
+81
View File
@@ -1,5 +1,86 @@
# Changelog - FlowDeck
## 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.
+6 -4
View File
@@ -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
+83 -7
View File
@@ -729,13 +729,89 @@ Détails livrés :
- [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)
- [ ] *(reporté v6.4)* : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events
#### 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.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)
- [ ] **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
@@ -776,11 +852,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-19 — **v6.1.0 Granular Permissions COMPLETED** (page/collection/property ACL, groupes, audit, 21 tests). Reste: v6.0.0 Pro (SSO/SAML, Web Clipper, API publique, realtime production)*
*Dernière mise à jour: 2026-09-21 — **v6.3.0 API publique complète v2 COMPLETED** (~100 endpoints `/api/v2`, scopes hiérarchiques `read<write<admin`, RFC 7807, idempotence, audit, webhooks CRUD, OpenAPI 402 chemins, 24 tests, suite 668 verte) + **v6.2.1 Web Clipper COMPLETED**. Reste: SSO/SAML, realtime production, synced blocks prod, webhooks HMAC/retry.*
+1 -1
View File
@@ -1 +1 @@
6.2.0
6.3.0
+4
View File
@@ -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"
+30 -12
View File
@@ -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
@@ -111,9 +113,9 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.2.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
version="6.3.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
@@ -157,6 +159,7 @@ 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")
@@ -237,12 +240,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)
+61
View File
@@ -843,6 +843,67 @@ def _migration_web_clipper(conn: sqlite3.Connection) -> None:
)
@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(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.
File diff suppressed because it is too large Load Diff
+16
View File
@@ -608,6 +608,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 &lt;token&gt;</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=&amp;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;">
+3
View File
@@ -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()
+310
View File
@@ -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
+23 -15
View File
@@ -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`.
---
@@ -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
+22 -21
View File
@@ -1,8 +1,8 @@
# V6.0.0 — Web Clipper : Extension Navigateur
# V6.2.0 — Web Clipper : Extension Navigateur
> **Statut** : Conception détaillée — v6.0.0
> **Date** : 2026-09-15
> **Route** : `feat/v6-web-clipper` → `develop` → `main`
> **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)
---
@@ -546,20 +546,20 @@ CREATE INDEX idx_clips_user ON extension_clips(user_id, created_at);
---
## 12. Checklist d'implémentation
## 12. Checklist d'implémentation — ✅ LIVRÉ (v6.2.0/6.2.1)
1. **`app/routers/web_clipper.py`** — endpoints clip et auth
2. **`app/services/web_clipper.py`** — service de traitement (extraction + création de page)
3. **Migrations DB** — `extension_devices`, `extension_clips`, colonne `source` sur `api_tokens`
4. **Readability.js intégré** dans le content script
5. **Extension côté client** — manifest.json, content.js, background.js, popup.html
6. **Settings UI** — section Extensions dans settings.html
7. **Page `/extensions`** — téléchargement
8. **OAuth pour extension** — device flow ou popup
9. **Content sanitization** — nettoyage HTML côté serveur
10. **Tests** — tous les scénarios de clipping
11. **Packaging** — Chrome Web Store / Firefox Add-on soumission
12. **Documentation utilisateur** — guide d'installation et usage
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
---
@@ -582,8 +582,9 @@ requests>=2.32 # Download images dans le service
- [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` — (à créer)
- `app/routers/web_clipper.py` — (à créer)
- `app/templates/settings.html` — section Extensions à ajouter
- `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.0.0 Web Clipper item
- `ROADMAP.md` — v6.2.0 Web Clipper (livré)
+21832
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+291
View File
@@ -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)
+5 -3
View File
@@ -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):
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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