From 9f402100579c948f7d1e4794372aa55daf2a93ae Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:38:16 +0000 Subject: [PATCH] feat(portal): M1 scoping gate + scoped cache endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_client_location() enforces ownership (sound location in one of the client's active projects) and 404s everything else — same response for missing and not-yours, so location existence never leaks. active_unit_for_location() resolves the currently-assigned SLM. Scoped GET /portal/api/location/{id}/live and /history: gate -> resolve unit -> read SLMM cache (never the device). /live returns a SCRUBBED projection (sound metrics + run state only; no battery/SD/raw_payload). Both degrade gracefully when there's no device or SLMM is down. Verified: ownership gate (owns / other-client / vibration / deleted-project / removed / nonexistent) + active-vs-completed unit resolution — 8/8 on a temp DB. Co-Authored-By: Claude Opus 4.8 --- backend/routers/portal.py | 90 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/backend/routers/portal.py b/backend/routers/portal.py index bc96006..1cc01c1 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -6,14 +6,18 @@ 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 logging +from datetime import datetime -from fastapi import APIRouter, Request, Depends +import httpx +from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import RedirectResponse +from sqlalchemy import or_ from sqlalchemy.orm import Session from backend.database import get_db -from backend.models import Client +from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( get_current_client, make_session_cookie, resolve_token, @@ -23,6 +27,44 @@ from backend.portal_auth import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/portal", tags=["portal"]) +SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + +# 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 + @router.get("/enter/{token}") def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): @@ -65,3 +107,47 @@ def portal_home(request: Request, client: Client = Depends(get_current_client)): "portal/overview.html", {"request": request, "client": client, "locations": []}, ) + + +# -- 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", [])}