feat: v6.3.0 API publique complete v2 (REST /api/v2, scopes, OpenAPI)
- 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:
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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.*
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+26
-8
@@ -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)
|
||||
@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": "Not found"}, status_code=404)
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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 <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;">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user