diff --git a/app/main.py b/app/main.py index 58a7e43..414702a 100644 --- a/app/main.py +++ b/app/main.py @@ -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"}], + } diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index ca1323f..29f94a9 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -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 diff --git a/app/routers/api.py b/app/routers/api.py index 9d47364..a79e868 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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, } diff --git a/app/routers/public_api.py b/app/routers/public_api.py new file mode 100644 index 0000000..789155c --- /dev/null +++ b/app/routers/public_api.py @@ -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 "} + + +@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]} diff --git a/app/routers/workspace.py b/app/routers/workspace.py index 29099fb..df6cd5f 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -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}") diff --git a/app/services/webhook_outbound.py b/app/services/webhook_outbound.py new file mode 100644 index 0000000..d67e5de --- /dev/null +++ b/app/services/webhook_outbound.py @@ -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() diff --git a/tests/test_app.py b/tests/test_app.py index 7629815..a3a8270 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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"