News that finds you, including when nothing of ours is open
The dots covered Reports and Messages from the day those sections existed. The announcement did not: only a chat reply produced an HX-Trigger, so a scheduled run that filed a report or posted into Messages lit a green dot in a corner and said nothing at all. That is precisely the arrival nobody is watching for -- a chat reply is one you asked for a moment ago and are probably looking at. So every kind announces, each with its own once-only flag, and the payload is a list of items rather than of titles, because a notification is a thing you click and a title cannot say where. One arrival, three channels, and they must not all fire. A toast for somebody looking at the page; a count in the tab title while it is hidden, cleared on focus; a system notification for somebody elsewhere entirely. The service worker is the only place that can tell them apart -- the server cannot see whether a window is focused and the page cannot see a push it did not receive -- so it stays quiet when one of its own windows has focus. And web push, hand-rolled against RFC 8291 and RFC 8292 with the cryptography already here for Fernet. It exists because everything else is polled by an open page, and the arrival worth interrupting somebody for is a schedule firing at seven in the morning with the laptop shut. The trade is real and is written down rather than glossed: the POST goes to Google's or Mozilla's push service, the payload is sealed end to end so they cannot read it, and what they do learn is that this server sent something and when. Opt-in per device, off until asked for, and the rest of the system works without it. Nothing else in LLeMbas contacts an outside service on its own. The encryption is tested by decrypting it back with an independent implementation of the specification's other half. There is no other way to know: a push service accepts the POST and forwards bytes it cannot read, so a wrong derivation is a notification that never appears, with a 201 in the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+41
-8
@@ -722,7 +722,7 @@ async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
|
||||
@router.get("/unread")
|
||||
async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
"""Dots for the sidebar, and a toast for anything newly arrived.
|
||||
"""Dots for the sidebar, and an announcement for anything newly arrived.
|
||||
|
||||
Polled rather than pushed: a browser sitting on a different chat has no
|
||||
open connection to the one that finished, and a second always-on channel
|
||||
@@ -730,6 +730,19 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
|
||||
Returns out-of-band spans so only the dots change -- re-rendering the whole
|
||||
sidebar would reset the folder open/closed state on every tick.
|
||||
|
||||
**Everything that can arrive is announced, not only chats.** The dots have
|
||||
covered Reports and Messages since those sections existed, but the
|
||||
announcement did not: only a chat reply produced an `HX-Trigger`, so a
|
||||
scheduled run that filed a report or posted into Messages lit a dot in the
|
||||
corner and said nothing at all. That is precisely the arrival nobody is
|
||||
watching for -- a chat reply is one you asked for a moment ago and are
|
||||
probably looking at, while a schedule fires while you are elsewhere. So each
|
||||
kind carries its own `*_notified` flag and each announces once.
|
||||
|
||||
The payload is a list of items rather than a list of titles, because the
|
||||
browser notification wants somewhere to go when it is clicked and a title on
|
||||
its own cannot say where.
|
||||
"""
|
||||
chats = list(
|
||||
db.scalars(
|
||||
@@ -749,11 +762,15 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
)
|
||||
)
|
||||
|
||||
# What to announce, in the order it will be read out. Each entry carries
|
||||
# where it came from and where to go, because a browser notification is a
|
||||
# thing you click.
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
fresh = [c for c in chats if c.unread and not c.unread_notified]
|
||||
for chat in fresh:
|
||||
chat.unread_notified = True
|
||||
if fresh:
|
||||
db.commit()
|
||||
items.append({"kind": "chat", "title": chat.title, "url": f"/chat/{chat.id}"})
|
||||
|
||||
markup = "".join(
|
||||
f'<span id="unread-{c.id}" class="unread-dot" hx-swap-oob="true"'
|
||||
@@ -771,6 +788,14 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
'<span id="unread-reports" class="unread-dot" hx-swap-oob="true"'
|
||||
f'{"" if waiting else " hidden"} title="New reports"></span>'
|
||||
)
|
||||
# Announced per report rather than per section, because the title is the
|
||||
# whole of what makes it worth interrupting somebody for -- "a report
|
||||
# arrived" is a sentence they have to go and act on to understand.
|
||||
for report in reports_service.unannounced(db, user):
|
||||
report.unread_notified = True
|
||||
items.append(
|
||||
{"kind": "report", "title": report.title, "url": f"/reports/{report.id}"}
|
||||
)
|
||||
|
||||
# The Messages conversation, read from the row rather than created: this
|
||||
# runs every ten seconds on every open page, and `for_user` would write one
|
||||
@@ -783,13 +808,21 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
f'{"" if (conversation and conversation.unread) else " hidden"}'
|
||||
' title="New messages"></span>'
|
||||
)
|
||||
if conversation is not None and conversation.unread and not conversation.unread_notified:
|
||||
conversation.unread_notified = True
|
||||
# Not the conversation's title, which is "Messages" and says nothing.
|
||||
# There is one per person and it is the section, so the section is the
|
||||
# honest name for it.
|
||||
items.append({"kind": "message", "title": "Messages", "url": "/messages"})
|
||||
|
||||
if items:
|
||||
db.commit()
|
||||
|
||||
response = HTMLResponse(markup)
|
||||
if fresh:
|
||||
# HX-Trigger carries the toast; ui.js listens for it.
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"lembas:unread": {"titles": [c.title for c in fresh]}}
|
||||
)
|
||||
if items:
|
||||
# HX-Trigger carries it; ui.js turns it into a toast, a browser
|
||||
# notification and a count in the tab title.
|
||||
response.headers["HX-Trigger"] = json.dumps({"lembas:unread": {"items": items}})
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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 push as push_service
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user