"""Registering a browser for notifications, and letting it go again. Three routes and no cleverness. The interesting half is `services/push.py`; this is the part a browser talks to. Ownership is the whole authorisation, as everywhere a person's own things are handled here: a subscription belongs to whoever was signed in when it was made, and nothing else can reach it. """ from __future__ import annotations import logging from fastapi import APIRouter, Request, Response, status from sqlalchemy import select from lembas.api.deps import Db, RequiredUser from lembas.db.models import PushSubscription from lembas.services import fetch as fetch_service from lembas.services import push as push_service from lembas.services.fetch import FetchError log = logging.getLogger(__name__) router = APIRouter(prefix="/api/push", tags=["push"]) # What a browser hands back is its own; these are the bounds that stop a crafted # POST writing a novel into the row. MAX_ENDPOINT = 2000 MAX_KEY = 255 @router.get("/key") async def application_key(db: Db, user: RequiredUser) -> dict[str, str]: """The public half of this instance's VAPID key. A browser needs it to subscribe, and it is public by construction — it is what every push service is shown on every send. Behind a login anyway, because there is no reason for it to be readable by anyone who is not about to use it. """ return {"key": push_service.public_key(db)} @router.post("/subscribe") async def subscribe(request: Request, db: Db, user: RequiredUser) -> Response: """Store what `pushManager.subscribe` handed back. Idempotent on the endpoint, because a browser that re-subscribes returns the same one — and two rows for one browser would be two notifications for one arrival. Re-subscribing also **re-points it at whoever is signed in now**: the endpoint belongs to the browser, so on a shared machine the second person to turn notifications on must get them instead of the first, not as well. """ payload = await request.json() endpoint = str(payload.get("endpoint") or "").strip()[:MAX_ENDPOINT] keys = payload.get("keys") or {} p256dh = str(keys.get("p256dh") or "").strip()[:MAX_KEY] auth = str(keys.get("auth") or "").strip()[:MAX_KEY] if not endpoint.startswith("https://") or not p256dh or not auth: return Response(status_code=status.HTTP_400_BAD_REQUEST) # The endpoint is a URL the browser hands us and the server later POSTs to, # which makes it the same shape as every other URL a request can name -- # and it was the one outbound client in the codebase not going through the # SSRF guard. `https://` alone says nothing about *where*: an internal # address is as valid a URL as Mozilla's push service, and the caller # triggers delivery themselves by sending a message and closing the tab. # # Checked here **and** again before the POST, the split `agent/hosts.py` # uses: a row can predate a DNS change, and this one is stored. try: fetch_service.check_url(endpoint) except FetchError as exc: log.warning("refused a push endpoint from %s: %s", user.email, exc.message) return Response(status_code=status.HTTP_400_BAD_REQUEST) existing = db.scalars( select(PushSubscription).where(PushSubscription.endpoint == endpoint) ).first() if existing is not None: existing.user_id = user.id existing.p256dh = p256dh existing.auth_secret = auth existing.last_error = "" else: db.add( PushSubscription( user_id=user.id, endpoint=endpoint, p256dh=p256dh, auth_secret=auth, label=str(request.headers.get("user-agent") or "")[:200], ) ) db.commit() log.info("%s registered a browser for notifications", user.email) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/unsubscribe") async def unsubscribe(request: Request, db: Db, user: RequiredUser) -> Response: """Forget one browser. Answers 204 whether or not there was anything to delete: the browser has already dropped its own subscription by the time it calls this, and telling it that the row was missing gives it nothing it could do about it. """ payload = await request.json() endpoint = str(payload.get("endpoint") or "").strip() row = db.scalars( select(PushSubscription).where( PushSubscription.endpoint == endpoint, PushSubscription.user_id == user.id ) ).first() if row is not None: db.delete(row) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)