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()