""" Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md). M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed session cookie, then sees their locations (overview) and per-location read-only live data sourced from SLMM's cache. Every data route re-checks ownership. """ import os import json import asyncio import logging from datetime import datetime import httpx import websockets from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket from fastapi.responses import RedirectResponse from sqlalchemy import or_ from sqlalchemy.orm import Session from backend.database import get_db, SessionLocal from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( get_current_client, client_from_cookie, make_session_cookie, resolve_token, COOKIE_NAME, COOKIE_MAX_AGE, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/portal", tags=["portal"]) SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://") # Whitelist of fields the portal exposes to a client — sound metrics + run state # only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed. _PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time", "lp", "leq", "lmax", "lpeak", "ln1", "ln2") # -- scoping (every data route gates through these) -------------------------- def _client_project_ids(client: Client, db: Session) -> list: return [r[0] for r in db.query(Project.id).filter( Project.client_id == client.id, Project.status != "deleted").all()] def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation: """Ownership gate: location must be a sound location in one of the client's active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so we never leak whether a location exists.""" loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first() if (not loc or loc.location_type != "sound" or loc.project_id not in _client_project_ids(client, db)): raise HTTPException(status_code=404, detail="Location not found") return loc def active_unit_for_location(location_id: str, db: Session): """The SLM unit currently assigned to this location, or None.""" now = datetime.utcnow() asg = (db.query(UnitAssignment) .filter(UnitAssignment.location_id == location_id, UnitAssignment.status == "active", UnitAssignment.device_type == "slm", or_(UnitAssignment.assigned_until.is_(None), UnitAssignment.assigned_until > now)) .order_by(UnitAssignment.assigned_at.desc()).first()) return asg.unit_id if asg else None def _client_locations(client: Client, db: Session) -> list: """The client's active sound locations (for the overview tiles + map).""" pids = _client_project_ids(client, db) if not pids: return [] projs = {p.id: p.name for p in db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()} locs = (db.query(MonitoringLocation) .filter(MonitoringLocation.project_id.in_(pids), MonitoringLocation.location_type == "sound", MonitoringLocation.removed_at.is_(None)) .order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()) return [{ "id": loc.id, "name": loc.name, "address": loc.address, "coordinates": loc.coordinates, "project_name": projs.get(loc.project_id), "has_device": active_unit_for_location(loc.id, db) is not None, } for loc in locs] @router.get("/enter/{token}") def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): """Magic-URL entry: validate the token, mint a session cookie, land on /portal.""" tok, client = resolve_token(token, db) if not client: return templates.TemplateResponse( "portal/access_required.html", {"request": request, "reason": "invalid"}, status_code=403, ) resp = RedirectResponse(url="/portal", status_code=303) resp.set_cookie( COOKIE_NAME, make_session_cookie(tok.id), max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", ) logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}") return resp @router.get("/logout") def portal_logout(): resp = RedirectResponse(url="/portal/access", status_code=303) resp.delete_cookie(COOKIE_NAME) return resp @router.get("/access") def portal_access(request: Request): """Landing for an unauthenticated visitor (no valid link).""" return templates.TemplateResponse( "portal/access_required.html", {"request": request, "reason": "required"} ) @router.get("") def portal_home(request: Request, client: Client = Depends(get_current_client), db: Session = Depends(get_db)): """Client overview — their active sound locations with live tiles + a map.""" return templates.TemplateResponse( "portal/overview.html", {"request": request, "client": client, "locations": _client_locations(client, db)}, ) @router.get("/location/{location_id}") def portal_location(location_id: str, request: Request, client: Client = Depends(get_current_client), db: Session = Depends(get_db)): """Read-only live view for one of the client's locations (404 if not owned).""" loc = resolve_client_location(client, location_id, db) return templates.TemplateResponse("portal/location.html", { "request": request, "client": client, "location": loc, "has_device": active_unit_for_location(location_id, db) is not None, }) # -- scoped data (cache reads only — never hits the device) ------------------ @router.get("/api/location/{location_id}/live") async def portal_location_live(location_id: str, client: Client = Depends(get_current_client), db: Session = Depends(get_db)): """Scrubbed cached live reading for a location the client owns.""" resolve_client_location(client, location_id, db) unit_id = active_unit_for_location(location_id, db) if not unit_id: return {"status": "ok", "data": None, "reason": "no_device"} try: async with httpx.AsyncClient(timeout=5.0) as hc: r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status") except Exception: return {"status": "ok", "data": None, "reason": "unreachable"} if r.status_code != 200: return {"status": "ok", "data": None, "reason": "no_data"} full = (r.json() or {}).get("data", {}) or {} return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}} @router.get("/api/location/{location_id}/history") async def portal_location_history(location_id: str, hours: float = 2.0, client: Client = Depends(get_current_client), db: Session = Depends(get_db)): """Cached chart trail for a location the client owns. (Trail rows are already just timestamp + lp/leq/lmax/ln1/ln2 — safe to pass through.)""" resolve_client_location(client, location_id, db) unit_id = active_unit_for_location(location_id, db) if not unit_id: return {"status": "ok", "readings": []} hours = max(0.1, min(hours, 48.0)) try: async with httpx.AsyncClient(timeout=5.0) as hc: r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history", params={"hours": hours}) except Exception: return {"status": "ok", "readings": []} if r.status_code != 200: return {"status": "ok", "readings": []} return {"status": "ok", "readings": (r.json() or {}).get("readings", [])} # -- live stream (fan-out feed, scoped + scrubbed) --------------------------- def _scrub_frame(raw: str) -> str: """Project a monitor frame down to the portal whitelist. Drops internal fields (unit_id, raw_payload, lmin) before it reaches a client; passes control fields (feed_status, heartbeat) + timestamp through.""" try: d = json.loads(raw) except Exception: return raw out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d} if "timestamp" in d: out["timestamp"] = d["timestamp"] for ctrl in ("feed_status", "heartbeat"): if ctrl in d: out[ctrl] = d[ctrl] return json.dumps(out) @router.websocket("/api/location/{location_id}/stream") async def portal_location_stream(websocket: WebSocket, location_id: str): """Live ~1Hz feed for a location the client owns. Auths via the session cookie, enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed to the browser (scrubbed). A viewer is just one more subscriber to the one device feed — no extra device connection.""" await websocket.accept() # Auth + ownership on a short-lived session, then release it for the long bridge. db = SessionLocal() try: client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db) if client is None: await websocket.close(code=1008) # policy violation (not authenticated) return try: resolve_client_location(client, location_id, db) except HTTPException: await websocket.close(code=1008) return unit_id = active_unit_for_location(location_id, db) finally: db.close() if not unit_id: try: await websocket.send_json({"feed_status": "no_device"}) finally: await websocket.close(code=1000) return target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor" backend_ws = None try: backend_ws = await websockets.connect(target) async def forward_to_client(): async for message in backend_ws: await websocket.send_text(_scrub_frame(message)) async def watch_client(): while True: await websocket.receive_text() tasks = [asyncio.ensure_future(forward_to_client()), asyncio.ensure_future(watch_client())] done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) for t in pending: t.cancel() for t in tasks: try: await t except (asyncio.CancelledError, Exception): pass except Exception as e: logger.warning(f"[PORTAL] stream {location_id}: {e}") finally: if backend_ws: try: await backend_ws.close() except Exception: pass