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
+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