feat(v2.1.0): API publique + Webhooks sortants + PWA
- 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:
+19
-3
@@ -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"}],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user