feat: read-only live view at /view?key=<LIVEVIEW_KEY>
Implements issue #5. Off by default; set LIVEVIEW_KEY in .env to enable. No JWT required — key-based auth via ?key= query param. Returns 403 when disabled or key is wrong. Read-only ReactFlow canvas (pan/zoom, no editing). Standalone mode loads from localStorage without a key. Includes 8 backend tests and 9 frontend tests.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def liveview_canvas(
|
||||
key: str | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CanvasStateResponse:
|
||||
"""Read-only public canvas endpoint.
|
||||
|
||||
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
|
||||
Always returns 403 when disabled, regardless of the key provided.
|
||||
"""
|
||||
if not settings.liveview_key:
|
||||
raise HTTPException(status_code=403, detail="Live view is disabled")
|
||||
if not key or key != settings.liveview_key:
|
||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
)
|
||||
@@ -30,6 +30,11 @@ class Settings(BaseSettings):
|
||||
# Leave empty to disable MCP service key auth.
|
||||
mcp_service_key: str = ""
|
||||
|
||||
# Live view — optional read-only public canvas endpoint.
|
||||
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
|
||||
# Leave unset (or empty) to keep the feature disabled (default).
|
||||
liveview_key: str | None = None
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, edges, nodes, scan, status
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.db.database import init_db
|
||||
@@ -40,6 +40,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Tests for the /api/v1/liveview read-only canvas endpoint.
|
||||
|
||||
The endpoint is:
|
||||
- Disabled by default (LIVEVIEW_KEY not set) → 403
|
||||
- Returns 403 for missing or wrong key even when enabled
|
||||
- Returns canvas data for a valid key (no JWT required)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_liveview_key():
|
||||
"""Restore liveview_key after each test so tests are isolated."""
|
||||
original = settings.liveview_key
|
||||
yield
|
||||
settings.liveview_key = original
|
||||
|
||||
|
||||
# ── Disabled (no key configured) ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_disabled_by_default(client: AsyncClient):
|
||||
settings.liveview_key = None
|
||||
res = await client.get("/api/v1/liveview?key=anything")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Live view is disabled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
|
||||
settings.liveview_key = ""
|
||||
res = await client.get("/api/v1/liveview?key=anything")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Live view is disabled"
|
||||
|
||||
|
||||
# ── Enabled but wrong / missing key ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_wrong_key(client: AsyncClient):
|
||||
settings.liveview_key = "correct-secret"
|
||||
res = await client.get("/api/v1/liveview?key=wrong-key")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Invalid live view key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_missing_key_param(client: AsyncClient):
|
||||
settings.liveview_key = "correct-secret"
|
||||
res = await client.get("/api/v1/liveview")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Invalid live view key"
|
||||
|
||||
|
||||
# ── Valid key — no JWT needed ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
|
||||
settings.liveview_key = "my-secret-key"
|
||||
res = await client.get("/api/v1/liveview?key=my-secret-key")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "nodes" in data
|
||||
assert "edges" in data
|
||||
assert "viewport" in data
|
||||
assert isinstance(data["nodes"], list)
|
||||
assert isinstance(data["edges"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_does_not_require_jwt(client: AsyncClient):
|
||||
"""Accessing without Authorization header must work when key is correct."""
|
||||
settings.liveview_key = "open-sesame"
|
||||
# client has no auth headers set here
|
||||
res = await client.get("/api/v1/liveview?key=open-sesame")
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
"""Canvas saved via POST /canvas/save appears in liveview response."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
|
||||
# Save a canvas with one node
|
||||
payload = {
|
||||
"nodes": [{
|
||||
"id": "lv-node-1",
|
||||
"type": "server",
|
||||
"label": "Live Node",
|
||||
"status": "online",
|
||||
"services": [],
|
||||
"pos_x": 10,
|
||||
"pos_y": 20,
|
||||
}],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
# Liveview should return the same node
|
||||
res = await client.get("/api/v1/liveview?key=test-key")
|
||||
assert res.status_code == 200
|
||||
nodes = res.json()["nodes"]
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0]["id"] == "lv-node-1"
|
||||
assert nodes[0]["label"] == "Live Node"
|
||||
|
||||
|
||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
||||
settings.liveview_key = "was-enabled"
|
||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||
assert res.status_code == 200
|
||||
|
||||
settings.liveview_key = None
|
||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Live view is disabled"
|
||||
Reference in New Issue
Block a user