373 lines
16 KiB
Python
373 lines
16 KiB
Python
"""
|
|
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
|
|
|
A client opens a per-project secure link (/portal/p/{link_token}), enters the
|
|
shared password, and gets a signed session cookie scoped to that project; they
|
|
then see that project's 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, Form
|
|
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,
|
|
COOKIE_NAME, COOKIE_MAX_AGE,
|
|
resolve_project_by_link_token, mint_portal_session,
|
|
is_locked, register_failure, clear_failures,
|
|
)
|
|
from backend.auth_passwords import verify_password
|
|
|
|
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("/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("/p/{link_token}")
|
|
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
|
|
"""Secure per-project link: resolve the project from the token, prompt for the
|
|
shared password. Generic page if the token is unknown/disabled (no leak)."""
|
|
project = resolve_project_by_link_token(link_token, db)
|
|
if not project or not project.portal_password_hash:
|
|
# unknown token, disabled portal, or enabled-but-no-password-set — all look
|
|
# identical to a client (no existence/config leak, no self-lockout on a
|
|
# passwordless project).
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
|
status_code=404)
|
|
return templates.TemplateResponse("portal/password.html", {
|
|
"request": request, "link_token": link_token,
|
|
"project_name": project.name, "error": None})
|
|
|
|
|
|
@router.post("/p/{link_token}")
|
|
def portal_password_submit(link_token: str, request: Request,
|
|
password: str = Form(...), db: Session = Depends(get_db)):
|
|
"""Verify the shared password; on success mint a project-scoped session cookie."""
|
|
project = resolve_project_by_link_token(link_token, db)
|
|
if not project or not project.portal_password_hash:
|
|
# unknown token, disabled portal, or enabled-but-no-password-set — all look
|
|
# identical to a client (no existence/config leak, no self-lockout on a
|
|
# passwordless project).
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
|
status_code=404)
|
|
|
|
lock_key = f"{link_token}:{request.client.host if request.client else '?'}"
|
|
if is_locked(lock_key):
|
|
return templates.TemplateResponse("portal/password.html", {
|
|
"request": request, "link_token": link_token, "project_name": project.name,
|
|
"error": "Too many attempts. Try again in 15 minutes."}, status_code=200)
|
|
|
|
if not verify_password(password, project.portal_password_hash):
|
|
register_failure(lock_key)
|
|
return templates.TemplateResponse("portal/password.html", {
|
|
"request": request, "link_token": link_token, "project_name": project.name,
|
|
"error": "Incorrect password."}, status_code=200)
|
|
|
|
clear_failures(lock_key)
|
|
token_id = mint_portal_session(project, db)
|
|
resp = RedirectResponse(url="/portal", status_code=303)
|
|
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
|
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
|
|
return resp
|
|
|
|
|
|
@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": []}
|
|
raw = (r.json() or {}).get("readings", [])
|
|
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
|
|
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
|
|
|
|
|
|
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
|
|
_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
|
|
"onset_value", "peak_value", "clear_at", "status")
|
|
|
|
|
|
@router.get("/api/location/{location_id}/events")
|
|
async def portal_location_events(location_id: str, limit: int = 20,
|
|
client: Client = Depends(get_current_client),
|
|
db: Session = Depends(get_db)):
|
|
"""Scrubbed breach history for a location the client owns (read-only)."""
|
|
resolve_client_location(client, location_id, db)
|
|
unit_id = active_unit_for_location(location_id, db)
|
|
if not unit_id:
|
|
return {"status": "ok", "events": []}
|
|
limit = max(1, min(limit, 100))
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
|
|
params={"limit": limit})
|
|
except Exception:
|
|
return {"status": "ok", "events": []}
|
|
if r.status_code != 200:
|
|
return {"status": "ok", "events": []}
|
|
raw = (r.json() or {}).get("events", [])
|
|
events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
|
|
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
|
|
|
|
|
|
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
|
|
# hysteresis internals).
|
|
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
|
|
"schedule_start", "schedule_end", "schedule_days")
|
|
|
|
|
|
@router.get("/api/location/{location_id}/thresholds")
|
|
async def portal_location_thresholds(location_id: str,
|
|
client: Client = Depends(get_current_client),
|
|
db: Session = Depends(get_db)):
|
|
"""The active alert limits for a location the client owns (enabled rules only),
|
|
so the client can see what they're being alerted on. Read-only, scrubbed."""
|
|
resolve_client_location(client, location_id, db)
|
|
unit_id = active_unit_for_location(location_id, db)
|
|
if not unit_id:
|
|
return {"status": "ok", "rules": []}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
|
|
except Exception:
|
|
return {"status": "ok", "rules": []}
|
|
if r.status_code != 200:
|
|
return {"status": "ok", "rules": []}
|
|
raw = (r.json() or {}).get("rules", [])
|
|
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
|
|
return {"status": "ok", "rules": rules}
|
|
|
|
|
|
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
|
|
|
def _scrub_frame(raw: 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. Returns None for a non-JSON frame
|
|
so the caller drops it rather than forwarding anything unscrubbed."""
|
|
try:
|
|
d = json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
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:
|
|
frame = _scrub_frame(message)
|
|
if frame is not None:
|
|
await websocket.send_text(frame)
|
|
|
|
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
|