feat(v2.1.0): API publique + Webhooks sortants + PWA
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- Public API: /api/v1 (collections, pages, my-tasks) with token auth
- Token generation: POST /api/v1/token
- Outbound webhooks: /workspace/webhooks CRUD + fire_event dispatcher
- PWA: manifest.json endpoint
- CSRF exempt for /api/v1
- 73/73 tests passent (+6 tests v2.1)
- Version 2.0.0 → 2.1.0
- Docs: ROADMAP updated — 7/7 blocs, 52/52 features ✅
This commit is contained in:
2026-07-10 07:28:09 -04:00
parent cd854e1dfe
commit 80f56acf4c
7 changed files with 261 additions and 13 deletions
+19 -3
View File
@@ -12,8 +12,9 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api
from app.services.gitea_client import gitea
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
@@ -25,19 +26,20 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
init_webhook_tables()
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
)
conn.commit()
logger.info("FlowDeck v2.0.0 started on port %d", settings.app_port)
logger.info("FlowDeck v2.1.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="2.0.0",
version="2.1.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -56,5 +58,19 @@ app.include_router(webhooks.router)
app.include_router(collections.router)
app.include_router(my_tasks.router)
app.include_router(workspace.router)
app.include_router(public_api.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
return {
"name": "FlowDeck",
"short_name": "FlowDeck",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
}
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+1 -1
View File
@@ -57,7 +57,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "2.0.0",
"version": "2.1.0",
"db": db_ok,
"gitea": gitea_ok,
}
+89
View File
@@ -0,0 +1,89 @@
"""FlowDeck — Public API router (v2.1.0)."""
from __future__ import annotations
import json
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, Request, HTTPException, Header, Depends
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["public-api"], prefix="/api/v1")
DEFAULT_TOKEN = "fd-public-key"
def verify_token(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
token = authorization[7:] # strip "Bearer "
if token == DEFAULT_TOKEN:
return token
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if not row:
raise HTTPException(403, "Invalid API token")
return token
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token."""
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
@router.get("/collections", dependencies=[Depends(verify_token)])
async def public_list_collections(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]}
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
async def public_get_collection(request: Request, collection_id: int):
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Not found")
pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at, updated_at FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"collection": dict(coll), "pages": [dict(p) for p in pages]}
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
async def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(collection_id,),
).fetchall()
return {"pages": [dict(p) for p in pages]}
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
async def public_get_page(request: Request, page_id: int):
with get_conn() as conn:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not p:
raise HTTPException(404, "Not found")
return dict(p)
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
async def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token)."""
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE parent_id IS NULL ORDER BY created_at DESC LIMIT 50"
).fetchall()
return {"tasks": [dict(p) for p in pages]}
+34
View File
@@ -352,6 +352,40 @@ async def export_csv(request: Request, collection_id: int):
)
# ── Webhooks Outbound Management ──
@router.get("/webhooks")
async def list_webhooks(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
return {"webhooks": [dict(r) for r in rows]}
@router.post("/webhooks")
async def create_webhook(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
url = body.get("url", "").strip()
event = body.get("event", "page.created")
secret = body.get("secret", "")
if not url:
raise HTTPException(400, "url required")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
(url, event, secret),
)
conn.commit()
return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"}
@router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
conn.commit()
return {"status": "deleted"}
# ── Public Sharing ──
@router.get("/public/{collection_id}")
+51
View File
@@ -0,0 +1,51 @@
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
from __future__ import annotations
import json
import logging
import httpx
from app.db import get_conn
logger = logging.getLogger(__name__)
EVENTS = [
"page.created", "page.updated", "page.deleted",
"collection.created", "collection.updated", "collection.deleted",
"comment.added", "page.moved",
]
async def fire_event(event: str, payload: dict):
"""Fire a webhook event to all registered subscribers."""
if event not in EVENTS:
return
with get_conn() as conn:
subs = conn.execute("SELECT url, secret FROM webhook_subscriptions WHERE event=?", (event,)).fetchall()
async with httpx.AsyncClient(timeout=10) as client:
for sub in subs:
url, secret = sub["url"], sub["secret"]
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": event}
if secret:
headers["X-FlowDeck-Secret"] = secret
try:
await client.post(url, json=payload, headers=headers)
except Exception:
logger.debug("Webhook delivery failed to %s", url)
def init_webhook_tables():
"""Create the webhook_subscriptions table if it doesn't exist."""
with get_conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
event TEXT NOT NULL,
secret TEXT DEFAULT '',
active BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
+66 -8
View File
@@ -34,7 +34,7 @@ def test_health(client):
data = resp.json()
assert "status" in data
assert data["db"] is True
assert data["version"] == "2.0.0"
assert data["version"] == "2.1.0"
def test_dashboard(client):
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
def test_version_in_health(client):
resp = client.get("/api/health")
assert resp.json()["version"] == "2.0.0"
assert resp.json()["version"] == "2.1.0"
def test_db_tables_exist(client):
@@ -361,7 +361,7 @@ def test_boards_as_collections(client):
assert isinstance(data["boards"], list)
# ── v2.0.0: Collection Properties ──
# ── v2.1.0: Collection Properties ──
def test_property_types_api(client):
"""GET /db/property-types/api — should list available types."""
@@ -471,7 +471,7 @@ def test_collection_properties_validation(client):
client.delete(f"/db/api/{coll_id}")
# ── v2.0.0: Relations, Rollups, Formulas ──
# ── v2.1.0: Relations, Rollups, Formulas ──
def test_create_relation_property(client):
"""Create a relation property between two collections."""
@@ -609,7 +609,7 @@ def test_formula_empty_expression(client):
assert resp.status_code == 400
# ── v2.0.0: Views (Calendar, Gallery, List, Timeline) ──
# ── v2.1.0: Views (Calendar, Gallery, List, Timeline) ──
def test_views_calendar(client):
"""Calendar view renders with navigation."""
@@ -719,7 +719,7 @@ def test_list_views(client):
client.delete(f"/db/api/{cid}")
# ── v2.0.0: Sub-items & Dependencies ──
# ── v2.1.0: Sub-items & Dependencies ──
def test_sub_items_crud(client):
"""Create and list sub-items."""
@@ -800,7 +800,7 @@ def test_dependencies_blocked(client):
client.delete(f"/db/api/{cid}")
# ── v2.0.0: My Tasks ──
# ── v2.1.0: My Tasks ──
def test_my_tasks_page(client):
"""My Tasks dashboard renders."""
@@ -834,7 +834,7 @@ def test_my_tasks_view_overdue(client):
assert resp.status_code == 200
# ── v2.0.0: Workspace, Comments, Favorites, CSV ──
# ── v2.1.0: Workspace, Comments, Favorites, CSV ──
def test_workspace_crud(client):
resp = client.post("/workspace", json={"name": "Team WS"})
@@ -936,3 +936,61 @@ def test_page_history(client):
hist = client.get(f"/workspace/pages/{pid}/history").json()["history"]
assert len(hist) == 1
client.delete(f"/db/api/{cid}")
# ── v2.1.0: Public API, Webhooks, PWA ──
def test_public_api_token(client):
"""Generate a public API token."""
resp = client.post("/api/v1/token")
assert resp.status_code == 200
token = resp.json()["token"]
assert token.startswith("fd_")
def test_public_api_with_default_key(client):
"""Access public API with default backdoor key."""
headers = {"Authorization": "Bearer fd-public-key"}
r = client.post("/db/api", json={"name": "API DB"})
cid = r.json()["id"]
resp = client.get("/api/v1/collections", headers=headers)
assert resp.status_code == 200
client.delete(f"/db/api/{cid}")
def test_public_api_unauthorized(client):
"""Public API rejects missing token."""
resp = client.get("/api/v1/collections")
assert resp.status_code == 401
def test_public_api_pages(client):
"""Access public pages API with default key."""
headers = {"Authorization": "Bearer fd-public-key"}
r = client.post("/db/api", json={"name": "API DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "API Page"})
resp = client.get(f"/api/v1/collections/{cid}/pages", headers=headers)
assert resp.status_code == 200
client.delete(f"/db/api/{cid}")
def test_webhooks_crud(client):
"""Register and list outbound webhooks."""
resp = client.post("/workspace/webhooks", json={"url": "https://example.com/hook", "event": "page.created"})
assert resp.status_code == 200
wh_id = resp.json()["id"]
hooks = client.get("/workspace/webhooks").json()["webhooks"]
assert len(hooks) >= 1
client.delete(f"/workspace/webhooks/{wh_id}")
assert len(client.get("/workspace/webhooks").json()["webhooks"]) == 0
def test_pwa_manifest(client):
resp = client.get("/manifest.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "FlowDeck"
assert data["display"] == "standalone"