diff --git a/CLAUDE.md b/CLAUDE.md index 0a4f382..93d4ec2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 1872 tests, ~2min +pytest # 1897 tests, ~2min # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; @@ -120,6 +120,9 @@ src/lembas/ instructions.py (the project's own AGENTS.md), patch.py (applying a unified diff, and rendering one) audio.py OpenAI-shaped /v1/audio/* client + push.py web push: the only thing here that talks to an outside + service, and only because there is no other way to reach + a browser that is closed fetch.py URL retrieval, HTML to text, the SSRF guard messages.py the one conversation per person: bounded in the request, unbounded on disk @@ -971,10 +974,48 @@ page load. The allowlist's lower bound, the handle's `data-resize-min` and the **Unread is polled, not pushed.** A browser on another chat has no connection to the one that finished. `/api/chats/unread` returns out-of-band dot spans and -an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being +an `HX-Trigger` carrying **items** — each with a kind, a title and a URL, because +a browser notification is a thing you click and a title alone cannot say where. +`unread_notified` (on `Chat` *and* on `Report`) stops the same arrival being announced every tick. Re-rendering the whole sidebar instead would reset the folder open/closed state every 10 seconds. +**Everything that can arrive is announced, not only chats.** The dots covered +Reports and Messages from the day those sections existed; the *announcement* did +not, so a scheduled run that filed a report lit a dot in a corner and said +nothing. That is exactly the arrival nobody is watching for — a chat reply is one +you asked for a moment ago. + +**One arrival, three channels, and they must not all fire.** A toast for somebody +looking at the page; a count in the tab title (`(3) LLeMbas`) while it is hidden, +cleared on focus; and a system notification for somebody elsewhere entirely. The +service worker is the only place that can tell them apart — it skips +`showNotification` when one of its own windows is `focused`, because the server +cannot see focus and the page cannot see a push it did not receive. + +**Web push is the one thing here that contacts an outside service.** +`services/push.py`, hand-rolled against RFC 8291 and RFC 8292 with +`cryptography`, which is already a dependency. It exists because everything else +is polled *by an open page*, and the arrival worth notifying about is a schedule +firing at seven in the morning with the browser shut. The trade is real and +written down in the module: the POST goes to Google's or Mozilla's push service, +the payload is encrypted 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**, because +the permission and the subscription both belong to a browser. + +Three things about it that are easy to get wrong: +- **`announce_later` is called where something arrives**, never from the poll — + the poll needs a page, and this is the case where there is not one. Each + arrival site runs exactly once, which is what makes it fire once with no flag + of its own; borrowing `unread_notified` would let whichever channel got there + first silence the other. It checks for a running loop **before** building the + coroutine, or every synchronous caller raises "never awaited" at its own line. +- **The VAPID keypair is generated once and never regenerated.** Its public half + is inside every subscription a browser holds, so a new one silently + invalidates all of them — notifications simply stop with nothing saying why. +- **404 and 410 delete the subscription**; anything else keeps it. Those two are + the normal end of a subscription's life, not a failure. + **Editing rewinds, it does not branch.** `POST .../messages/{id}/edit` rewrites a user turn and **deletes everything after it**. Branching would need a UI for choosing between versions; "go back and try again from here" is what was asked diff --git a/PLAN.md b/PLAN.md index 7c543df..ddf3849 100644 --- a/PLAN.md +++ b/PLAN.md @@ -9,7 +9,7 @@ reasoning, tool calling with web search, custom HTTP tools and MCP servers, agent chats that work on a machine over SSH, a knowledge library, notes, memory and skills, speech in and out, image generation over ComfyUI, users and groups, model administration, installable as an app, reports, messages, and scheduled -work that runs on its own. 1824 tests, `ruff` clean. +work that runs on its own. 1897 tests, `ruff` clean. What remains before the first stable release is written out below, in phases, under [The road to 1.0.0](#the-road-to-100). @@ -52,8 +52,10 @@ be a different project, not a refactor. are already an objective. Renameable from the heading and from the sidebar row; one response updates both - [x] Chats created on first message, so an abandoned composer leaves nothing -- [x] **Unread indicator** — a green dot and a toast when a reply lands while - you were elsewhere +- [x] **You are told when something arrives** — a dot and a toast for a reply, + a report or a scheduled run; a count in the tab title while you are + looking elsewhere; and a browser notification, opt-in per device, that + reaches you with LLeMbas closed - [x] **A reply that started without you asking still arrives** — the open chat page polls for turns it has not got, so a background job waking the model appears where you are looking instead of only after a reload. Quiet while @@ -422,7 +424,21 @@ seen working. `tool.notes` and `tool.memory` as well, because those are what the model actually reached for -### Phase 2 — image generation admin (`0.9.1`) +### Notifications (`0.9.1`) +- [x] **Everything that arrives is announced**, not only chat replies. The dots + covered Reports and Messages; the announcement did not, so a scheduled run + lit a dot in a corner and said nothing +- [x] **A count in the tab title** while you are looking elsewhere, cleared when + you come back +- [x] **Web push**, so a schedule firing at seven in the morning reaches a + browser that is shut. Hand-rolled against RFC 8291 and 8292 with the + `cryptography` already here. Opt-in per device, asked for once in a dialog + of ours before the browser's own — and the one thing in LLeMbas that + contacts an outside service, which `services/push.py` says plainly +- [x] One arrival never announced three times: the service worker stays quiet + when a window of its own has focus + +### Phase 2 — image generation admin (`0.9.2`) - [ ] **Defaults an administrator can set** — steps, cfg, size, sampler, scheduler, denoise, negative, batch. There were none: one hardcoded set from the SD1.5 era, and prose in a box as the only way to change it diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index d905ad1..9daafa2 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.9.0" +__version__ = "0.9.1" diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 192ef5f..be04790 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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' Response: '' ) + # 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">' ) + 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 diff --git a/src/lembas/api/push.py b/src/lembas/api/push.py new file mode 100644 index 0000000..a799fd0 --- /dev/null +++ b/src/lembas/api/push.py @@ -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) diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py index eeff9c7..04f2d7d 100644 --- a/src/lembas/db/models/__init__.py +++ b/src/lembas/db/models/__init__.py @@ -93,6 +93,7 @@ from lembas.db.models.user import ( ROLE_ADMIN, ROLE_PENDING, Group, + PushSubscription, Session, User, user_groups, @@ -100,6 +101,7 @@ from lembas.db.models.user import ( __all__ = [ "AUTHOR_MODEL", + "PushSubscription", "AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", diff --git a/src/lembas/db/models/report.py b/src/lembas/db/models/report.py index 37650ce..ca4730e 100644 --- a/src/lembas/db/models/report.py +++ b/src/lembas/db/models/report.py @@ -59,6 +59,13 @@ class Report(UUIDPrimaryKey, Timestamps, Base): # NOT NULL with a scalar default so `migrations._add_column_sql` can backfill # it if this column is ever added to a table that already has rows. unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # Whether its arrival has already been announced. The dot can be shown for + # as long as it is unread; the toast and the browser notification must fire + # once. Without this the poll would announce the same report every ten + # seconds until somebody opened it, which is the shape of notification + # nobody leaves switched on. `Chat.unread_notified` exists for exactly this + # and this is the same pair. + unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # Why a run produced nothing worth reading. A scheduled report that failed # is still a report -- one that silently did not appear is indistinguishable # from a schedule that never fired. diff --git a/src/lembas/db/models/user.py b/src/lembas/db/models/user.py index e9bb1fe..08c9f31 100644 --- a/src/lembas/db/models/user.py +++ b/src/lembas/db/models/user.py @@ -105,3 +105,47 @@ class Session(UUIDPrimaryKey, Timestamps, Base): Index("ix_sessions_user_id", Session.user_id) + + +class PushSubscription(UUIDPrimaryKey, Timestamps, Base): + """One browser, on one device, that has agreed to be told. + + Per device rather than per account, and that is not a detail: the permission + and the subscription both belong to a browser, so somebody signed in on a + laptop and a phone has two of these and revoking one must not silence the + other. It is also why there is no "notifications on" column on `User` -- the + presence of a row here *is* the state, and it cannot drift from what the + browser thinks. + + `endpoint` is chosen by the browser vendor and is the address their push + service will accept a message at. Unique, because a browser that + re-subscribes hands back the same one and two rows would mean two + notifications for one arrival. + + `p256dh` and `auth_secret` are the browser's half of the encryption. Stored + as the browser gave them, base64url: they are public key material and a + per-subscription salt, not credentials -- what they protect is the payload, + and a database holding them can already read everything the payload could + say. See services/push.py. + """ + + __tablename__ = "push_subscriptions" + + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + endpoint: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + p256dh: Mapped[str] = mapped_column(String(255), nullable=False) + auth_secret: Mapped[str] = mapped_column(String(64), nullable=False) + # Which device this is, for a list somebody can revoke from. Whatever the + # browser says about itself, trimmed; never parsed. + label: Mapped[str] = mapped_column(String(200), default="") + # The last refusal from the push service, kept so a subscription that has + # stopped working says why rather than being silently useless. A 404 or 410 + # deletes the row instead -- that is the end of its life, not a fault. + last_error: Mapped[str] = mapped_column(Text, default="") + + user: Mapped[User] = relationship() + + +Index("ix_push_subscriptions_user_id", PushSubscription.user_id) diff --git a/src/lembas/main.py b/src/lembas/main.py index 5d7b30a..b0d6a7f 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -35,6 +35,7 @@ from lembas.api import ( messages, pages, preferences, + push, reports, schedules, terminal, @@ -187,6 +188,7 @@ def create_app() -> FastAPI: app.include_router(admin_suggestions.router) app.include_router(admin_tools.router) app.include_router(admin_agents.router) + app.include_router(push.router) register_error_handlers(app) return app diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index 78051e6..14d886c 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -34,6 +34,7 @@ from lembas.services import compaction as compaction_service from lembas.services import interaction, settings_store, tokens, tool_labels from lembas.services import metrics as metrics_service from lembas.services import prompts as prompts_service +from lembas.services import push as push_service from lembas.services import tools as tools_service from lembas.services.agent import policy as agent_policy from lembas.services.agent import session as agent_session @@ -2018,6 +2019,19 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None: if generation.followers == 0 and not chat.temporary: chat.unread = True chat.unread_notified = False + # And out to any browser that asked to be told, which is the + # only channel that reaches somebody with nothing of ours open. + # Here rather than in the unread poll because the poll needs a + # page, and this is exactly the case where there is not one: + # `followers == 0` says so. Fire and forget -- the reply is + # finished and nothing about it should wait on a push service. + push_service.announce_later( + chat.user_id, + title=chat.title or "New reply", + body="Your reply is ready.", + url=f"/chat/{chat.id}", + kind="chat", + ) db.commit() log.debug( diff --git a/src/lembas/services/push.py b/src/lembas/services/push.py new file mode 100644 index 0000000..a55d80e --- /dev/null +++ b/src/lembas/services/push.py @@ -0,0 +1,401 @@ +"""Web Push: a notification that arrives with nothing of ours running. + +Everything else here is polled. `/api/chats/unread` runs in an open page, which +is enough for "a reply landed while you were on another chat" and is nothing at +all for the case this exists for -- a schedule firing at 07:00 on a laptop whose +browser is shut. There is no way to close that gap from inside a page, because +there is no page. + +## The trade, stated plainly + +A push goes to the **browser vendor's** push service: Google's for Chrome, +Mozilla's for Firefox, Apple's for Safari. The endpoint is chosen by the +browser and there is no version of this feature that avoids it. That sits +against "a self-hosted tool must not report page views to a third party", and +the answer is not that the tension is imaginary: + +- The payload is encrypted end to end (RFC 8291) with a key derived from a + secret only the browser and this server hold, so the push service carries + bytes it cannot read. +- What it *does* learn is that this server sent something to that subscription, + and when. On a personal instance that is a timing channel over your own + activity, and it is real. +- So it is **opt-in per device**, off until somebody presses the button, and + the rest of the notification system works without it. + +Nothing else in LLeMbas contacts an outside service on its own. + +## Hand-rolled, and why + +`pywebpush` would do this in three lines and bring `http-ece` and `py-vapid` +with it. The encryption below is one ECDH, two HKDFs and one AES-GCM seal, all +from `cryptography`, which is already a dependency because API keys are +Fernet-encrypted. That is the same call the MCP client makes: a hand-written +client, so what actually goes on the wire is in this repository. + +## The two specifications + +**RFC 8291** is the payload: `aes128gcm`, one record, the salt and the server's +ephemeral public key carried in the body's own header block. + +**RFC 8292** is the authorisation: a JWT signed with a P-256 key whose public +half identifies this server. The keypair is generated once and kept in the +settings table with the private half Fernet-encrypted. It must be **stable** -- +the public key is baked into every subscription a browser has made, so +regenerating it silently invalidates all of them. + +## Failure is a subscription being dropped, not an error + +A push service answers 404 or 410 for a subscription that no longer exists -- +the browser was uninstalled, the site's data cleared, the permission revoked. +That is the normal end of a subscription's life and not a fault, so those two +delete the row. Everything else is logged and left, because a push service +having a bad hour is not a reason to lose somebody's registration. +""" + +from __future__ import annotations + +import base64 +import hmac +import json +import logging +import os +import time +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any +from urllib.parse import urlsplit + +import httpx +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import utils as asym_utils +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from lembas.db.models import PushSubscription, User +from lembas.services import settings_store +from lembas.services.crypto import decrypt, encrypt + +log = logging.getLogger(__name__) + +# How long a push service should hold an undelivered message. Four hours: long +# enough that a laptop opened after lunch still gets the morning's report, short +# enough that nothing arrives claiming to be news when it is a day old. +TTL_SECONDS = 4 * 3600 + +# The JWT's life. Twelve hours is the maximum RFC 8292 allows, and a short one +# buys nothing here -- it is minted per request. +JWT_SECONDS = 12 * 3600 + +# Bigger than any payload we send; the record size field must still be present +# and must exceed the ciphertext. +RECORD_SIZE = 4096 + +# What a payload may carry. A push service will refuse a large body outright, +# and there is nothing here worth more than a title and a line. +MAX_PAYLOAD_BYTES = 3000 + +SETTING_PRIVATE = "push_private_key" +SETTING_PUBLIC = "push_public_key" + + +# --- base64url, without padding, everywhere ------------------------------------ +def b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def unb64(text: str) -> bytes: + padded = text + "=" * (-len(text) % 4) + return base64.urlsafe_b64decode(padded.encode("ascii")) + + +# --- The server's identity ----------------------------------------------------- +@dataclass(frozen=True) +class Keys: + private: ec.EllipticCurvePrivateKey + public_b64: str + + +def keys(db: DBSession) -> Keys: + """This instance's VAPID keypair, generated once and kept. + + Generated on first use rather than by a setup step, because a feature that + needs somebody to run a command before it works is one that is off on every + instance that did not read the release notes. The private half is Fernet- + encrypted at rest, like every other secret here. + + **Never regenerated.** The public key is inside every subscription a browser + holds, so a new one silently invalidates all of them -- notifications simply + stop, with nothing anywhere saying why. + """ + stored = settings_store.get(db, SETTING_PRIVATE) + if stored: + private = serialization.load_pem_private_key(decrypt(str(stored)).encode(), password=None) + return Keys(private=private, public_b64=str(settings_store.get(db, SETTING_PUBLIC) or "")) + + private = ec.generate_private_key(ec.SECP256R1()) + pem = private.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + public_b64 = b64(_raw_public(private.public_key())) + settings_store.update(db, {SETTING_PRIVATE: encrypt(pem), SETTING_PUBLIC: public_b64}) + log.info("generated a VAPID keypair for web push") + return Keys(private=private, public_b64=public_b64) + + +def public_key(db: DBSession) -> str: + """What a browser needs in order to subscribe.""" + return keys(db).public_b64 + + +def _raw_public(key: ec.EllipticCurvePublicKey) -> bytes: + """The uncompressed 65-byte point, which is the only form either spec uses.""" + return key.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + + +# --- RFC 8292: proving who is asking ------------------------------------------- +def _jwt(private: ec.EllipticCurvePrivateKey, audience: str, subject: str) -> str: + header = b64(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode()) + claims = b64( + json.dumps( + {"aud": audience, "exp": int(time.time()) + JWT_SECONDS, "sub": subject}, + separators=(",", ":"), + ).encode() + ) + signing_input = f"{header}.{claims}".encode() + + der = private.sign(signing_input, ec.ECDSA(hashes.SHA256())) + # JWS wants the raw pair, not DER. `cryptography` only signs to DER, so it + # is decoded and re-emitted fixed-width -- a leading zero dropped here is a + # signature every push service rejects, and the error it gives is 401. + r, s = asym_utils.decode_dss_signature(der) + raw = r.to_bytes(32, "big") + s.to_bytes(32, "big") + return f"{header}.{claims}.{b64(raw)}" + + +def _audience(endpoint: str) -> str: + parts = urlsplit(endpoint) + return f"{parts.scheme}://{parts.netloc}" + + +# --- RFC 8291: the payload ------------------------------------------------------ +def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes: + """Extract-then-expand, written out because the two halves take different + salts here and `HKDF` in one call cannot express that.""" + prk = hmac.new(salt, ikm, sha256).digest() + return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info).derive(prk) + + +def encrypt_payload(payload: bytes, *, p256dh: str, auth: str) -> bytes: + """One `aes128gcm` record, ready to be the body of the POST. + + The layout is the specification's, and the order matters to a parser that + has never seen our code: + + salt (16) | record size (4) | key id length (1) | server key (65) | ct + """ + client_public = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), unb64(p256dh) + ) + auth_secret = unb64(auth) + + server_private = ec.generate_private_key(ec.SECP256R1()) + server_public = _raw_public(server_private.public_key()) + shared = server_private.exchange(ec.ECDH(), client_public) + + # The first HKDF is salted with the subscription's own auth secret and its + # info binds both public keys, which is what stops a captured record being + # replayed at a different subscriber. + key_info = b"WebPush: info\x00" + unb64(p256dh) + server_public + ikm = _hkdf(auth_secret, shared, key_info, 32) + + salt = os.urandom(16) + content_key = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16) + nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12) + + # 0x02 is the padding delimiter for the last (here, only) record. 0x01 would + # say another follows, and a receiver would wait for it. + ciphertext = AESGCM(content_key).encrypt(nonce, payload + b"\x02", None) + + return ( + salt + + RECORD_SIZE.to_bytes(4, "big") + + len(server_public).to_bytes(1, "big") + + server_public + + ciphertext + ) + + +# --- Sending -------------------------------------------------------------------- +def subject_for(db: DBSession) -> str: + """The `sub` claim: who to contact about this server's pushes. + + A URL is as acceptable as a mailto and needs nothing configured, so the + instance's own base URL is used when there is one. Push services require the + claim to be present; none of them checks that it resolves. + """ + configured = str(settings_store.get(db, "public_url") or "").strip() + return configured or "https://lembas.invalid" + + +async def send_one(db: DBSession, subscription: PushSubscription, payload: dict[str, Any]) -> bool: + """Deliver to one registration. True if it was accepted. + + Never raises: this runs from arrival paths that must not fail because a push + service is having a bad hour. + """ + body = json.dumps(payload, separators=(",", ":")).encode() + if len(body) > MAX_PAYLOAD_BYTES: # pragma: no cover - titles are bounded already + body = json.dumps({"title": payload.get("title", "LLeMbas")}).encode() + + try: + encrypted = encrypt_payload( + body, p256dh=subscription.p256dh, auth=subscription.auth_secret + ) + token = _jwt(keys(db).private, _audience(subscription.endpoint), subject_for(db)) + except Exception: # noqa: BLE001 - a malformed stored key must not kill a reply + log.exception("could not build a push for %s", subscription.id) + return False + + headers = { + "TTL": str(TTL_SECONDS), + "Content-Encoding": "aes128gcm", + "Content-Type": "application/octet-stream", + # "high" would let a phone wake for it; this is news, not an alarm. + "Urgency": "normal", + "Authorization": f"vapid t={token}, k={keys(db).public_b64}", + } + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(subscription.endpoint, content=encrypted, headers=headers) + except httpx.RequestError as exc: + log.warning("push to %s failed: %s", _audience(subscription.endpoint), exc) + return False + + if response.status_code in (404, 410): + # The normal end of a subscription's life: uninstalled, cleared, or the + # permission revoked. Deleting it is the correct response and not an + # error -- keeping it would mean retrying forever against a dead address. + log.info("push subscription %s is gone; removing it", subscription.id) + db.delete(subscription) + db.commit() + return False + if response.status_code >= 400: + subscription.last_error = f"{response.status_code}: {response.text[:200]}" + db.commit() + log.warning( + "push to %s refused: %s %s", + _audience(subscription.endpoint), + response.status_code, + response.text[:200], + ) + return False + + if subscription.last_error: + subscription.last_error = "" + db.commit() + return True + + +def subscriptions_for(db: DBSession, user: User | None) -> list[PushSubscription]: + if user is None: + return [] + return list( + db.scalars(select(PushSubscription).where(PushSubscription.user_id == user.id)) + ) + + +async def announce( + db: DBSession, user: User | None, *, title: str, body: str, url: str = "", kind: str = "" +) -> int: + """Tell every device this person has registered. Returns how many took it. + + Called at the moment something arrives rather than from the poll, because + the whole point is the case where no page is open to poll. A device with a + page open gets this *and* the in-page toast -- the service worker resolves + that by not showing a notification when one of its own windows is focused, + which is the only place that can be known. + """ + subscriptions = subscriptions_for(db, user) + if not subscriptions: + return 0 + + payload = {"title": title, "body": body, "url": url, "kind": kind} + delivered = 0 + for subscription in list(subscriptions): + if await send_one(db, subscription, payload): + delivered += 1 + return delivered + + +# Fire-and-forget tasks, held so the event loop does not collect one mid-flight. +# asyncio keeps only a weak reference to a task nobody awaits, and a push that +# vanishes halfway is the kind of intermittent nobody reproduces. +_TASKS: set[Any] = set() + + +def announce_later(user_id: str, *, title: str, body: str, url: str = "", kind: str = "") -> None: + """Announce from a path that must not wait for it, and must not fail with it. + + Called where something *arrives* -- a reply finishing with nobody watching, + a report being filed, a run posting into Messages -- rather than from the + poll. That is the whole point: the poll needs an open page, and the case + worth a notification is the one where there is none. + + Each of those sites runs exactly once per arrival, which is what makes this + fire once with no "already notified" flag of its own. `unread_notified` is + the *page's* record of having toasted; borrowing it here would mean whichever + channel got there first silenced the other. + + Its own session, opened inside the task: the caller's is usually about to be + committed and closed, and holding one open across a POST to somebody else's + server is how a request comes to wait on a push service having a bad day. + """ + import asyncio + + from lembas.db.session import session_scope + + # The loop is checked *before* the coroutine is built, not by catching what + # `create_task` raises without one. A coroutine made and then dropped is a + # "never awaited" RuntimeWarning from wherever it was created -- which here + # is every synchronous caller in the suite and every CLI command that files + # a report. The warning would be the only symptom, and it would be + # attributed to the caller rather than to this. + try: + asyncio.get_running_loop() + except RuntimeError: + return + + async def run() -> None: + try: + with session_scope() as db: + user = db.get(User, user_id) + if user is None: + return + await announce(db, user, title=title, body=body, url=url, kind=kind) + except Exception: # noqa: BLE001 - nothing upstream can act on this + log.exception("could not announce to %s", user_id) + + task = asyncio.create_task(run()) + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + + +__all__ = [ + "announce", + "announce_later", + "encrypt_payload", + "keys", + "public_key", + "send_one", + "subscriptions_for", +] diff --git a/src/lembas/services/reports.py b/src/lembas/services/reports.py index cb941b9..5ce0119 100644 --- a/src/lembas/services/reports.py +++ b/src/lembas/services/reports.py @@ -85,6 +85,32 @@ def unread_count(db: DBSession, user: User | None) -> int: ) +def unannounced(db: DBSession, user: User | None) -> list[Report]: + """Reports that have arrived and have not been announced yet. + + Separate from `unread_count`, which drives the dot: the dot may be shown for + as long as something is unread, while an announcement fires once. Reading + them apart is what stops the poll interrupting somebody every ten seconds + with the same report until they open it. + + Ordered oldest first, so several arriving between two ticks are announced in + the order they were filed. + """ + if user is None: + return [] + return list( + db.scalars( + select(Report) + .where( + Report.owner_id == user.id, + Report.unread.is_(True), + Report.unread_notified.is_(False), + ) + .order_by(Report.created_at) + ) + ) + + def _first_line(body: str) -> str: """A summary for a model that did not write one. @@ -137,6 +163,22 @@ def create( ) db.add(report) db.commit() + + # Here rather than at the scheduled-run site, because a report is filed from + # two places -- a schedule delivering one, and a model calling `report_write` + # in a chat nobody stayed on -- and both are arrivals somebody would want to + # know about. `unread` is what says it is news; a report filed with it off + # was made by the person reading this screen. + if report.unread: + from lembas.services import push as push_service + + push_service.announce_later( + report.owner_id, + title=report.title or "Report filed", + body=report.summary or "A report is waiting for you.", + url=f"/reports/{report.id}", + kind="report", + ) return report diff --git a/src/lembas/services/schedule/runner.py b/src/lembas/services/schedule/runner.py index 2372bee..627b54c 100644 --- a/src/lembas/services/schedule/runner.py +++ b/src/lembas/services/schedule/runner.py @@ -122,6 +122,18 @@ def _finished_reply(db, chat_id: str, message_id: str) -> Message | None: return message +def _preview(text: str, limit: int = 160) -> str: + """The opening of a reply, as the body of a notification. + + A notification saying "a scheduled run finished" is one somebody has to open + something to understand, which is most of the reason notifications get + ignored. Flattened to one line because a push service and an operating + system will each do their own thing with newlines. + """ + flat = " ".join((text or "").split()) + return flat[: limit - 1] + "…" if len(flat) > limit else flat + + async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None: """Put a finished reply where the schedule said it should go. @@ -215,6 +227,20 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None conversation.unread_notified = False db.commit() + # The arrival this whole channel exists for: a run that fired while + # nobody was here, landing somewhere they are not looking. The first + # line of the reply is the body, because "a scheduled run finished" + # is a notification you have to open something to understand. + from lembas.services import push as push_service + + push_service.announce_later( + owner.id, + title=schedule.title or "Scheduled run", + body=_preview(message.content or ""), + url="/messages", + kind="message", + ) + async def fire(schedule_id: str, *, due_at: datetime | None = None) -> None: """Run one schedule now. diff --git a/src/lembas/web/static/js/sw.js b/src/lembas/web/static/js/sw.js index 1419998..2be0096 100644 --- a/src/lembas/web/static/js/sw.js +++ b/src/lembas/web/static/js/sw.js @@ -120,3 +120,72 @@ self.addEventListener("fetch", function (event) { }) ); }); + +/* + Notifications that arrive with no page open. + + This is the only part of LLeMbas that runs when nothing of ours is on screen, + and it is why web push exists here at all: everything else is polled by an open + page, which is exactly what is missing at seven in the morning when a schedule + fires and the laptop is shut. + + The payload was encrypted end to end (see services/push.py), so what arrives + here is the first plaintext anybody but this browser and that server has seen. +*/ +self.addEventListener("push", function (event) { + var payload = {}; + try { + payload = event.data ? event.data.json() : {}; + } catch (error) { + payload = { title: "LLeMbas", body: "Something new arrived." }; + } + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) { + /* Somebody is looking at it. The page has its own toast and its own + count in the tab title, and a system notification on top of those is + the same news three times -- which is how notifications come to be + switched off for good. This is the only place that can be known: the + server cannot see whether a window is focused, and the page cannot see + a push that it did not receive. */ + for (var i = 0; i < clients.length; i++) { + if (clients[i].focused) return null; + } + return self.registration.showNotification(payload.title || "LLeMbas", { + body: payload.body || "", + /* One at a time. A browser left closed all day must not be opened to a + stack of twelve. */ + tag: "lembas-" + (payload.kind || "unread"), + renotify: true, + icon: "/static/img/icon-192.png", + badge: "/static/img/icon-192.png", + data: { url: payload.url || "/" }, + }); + }) + ); +}); + +/* + Clicking one. + + Focus a window that is already open rather than opening a second: somebody + with LLeMbas open in a tab wants that tab, and `openWindow` would give them + two. `navigate` moves the one they have to whatever arrived. +*/ +self.addEventListener("notificationclick", function (event) { + event.notification.close(); + var target = (event.notification.data && event.notification.data.url) || "/"; + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) { + for (var i = 0; i < clients.length; i++) { + var client = clients[i]; + if (new URL(client.url).origin !== self.location.origin) continue; + return client.focus().then(function (focused) { + return focused && focused.navigate ? focused.navigate(target) : focused; + }); + } + return self.clients.openWindow(target); + }) + ); +}); diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 5b5bdfc..350ad10 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -434,22 +434,330 @@ })(); /* - Unread replies. + Something arrived. The sidebar polls /api/chats/unread; the response carries out-of-band spans - for the dots and, when something has just landed, an HX-Trigger asking for a - toast. Announcing it here rather than server-side keeps the wording and the - timing in one place. -*/ -document.addEventListener("lembas:unread", function (event) { - var titles = (event.detail && event.detail.titles) || []; - if (!titles.length || !window.lembas || !window.lembas.notify) return; + for the dots and, when something has just landed, an HX-Trigger listing it. + Announcing it here rather than server-side keeps the wording and the timing in + one place. - var message = titles.length === 1 - ? "Reply ready in “" + titles[0] + "”" - : titles.length + " chats have new replies"; - window.lembas.notify(message, { kind: "success", timeout: 6000 }); -}); + Three things happen, and they are deliberately not the same thing three times: + + - A **toast**, always. It is the answer for somebody who is looking at the + page, and it is the only one of the three that needs no permission and + cannot be switched off by an operating system. + - A **count in the tab title**, while the tab is not the one being looked at. + This is the part that was missing and that nothing else replaces: a schedule + that fires while you are in another tab lit a green dot in a corner you + could not see. Cleared the moment the page is looked at again, because a + badge you have to dismiss is worse than none. + - A **browser notification**, if the reader has asked for one. Only while the + page is hidden -- notifying somebody about something they are watching + happen is the behaviour that gets notifications turned off for good. +*/ +(function () { + var NOTIFY_KEY = "lembas-desktop-notifications"; + /* Kept per browser rather than on the account, because the *permission* is + per browser and per origin. A preference that followed somebody to a + machine where they had never granted it would be a switch that reads "on" + and does nothing. */ + var baseTitle = document.title; + var pending = 0; + + function wanted() { + try { + return window.localStorage.getItem(NOTIFY_KEY) === "1"; + } catch (error) { + return false; + } + } + + function setWanted(on) { + try { + window.localStorage.setItem(NOTIFY_KEY, on ? "1" : "0"); + } catch (error) { /* private mode; the toast still works */ } + } + + function retitle() { + document.title = pending > 0 ? "(" + pending + ") " + baseTitle : baseTitle; + } + + /* The title is rewritten by navigation and by a rename arriving out of band, + so the base is re-read rather than captured once. Without this, renaming a + chat while something is unread would pin the old name until a reload. */ + function rebase() { + var shown = document.title; + var stripped = shown.replace(/^\(\d+\)\s*/, ""); + if (stripped !== baseTitle) { baseTitle = stripped; retitle(); } + } + + function clear() { + if (!pending) return; + pending = 0; + retitle(); + } + + document.addEventListener("visibilitychange", function () { + if (!document.hidden) clear(); + }); + window.addEventListener("focus", clear); + + function describe(items) { + if (items.length > 1) return items.length + " new arrivals"; + var item = items[0]; + if (item.kind === "report") return "Report filed: “" + item.title + "”"; + if (item.kind === "message") return "New message"; + return "Reply ready in “" + item.title + "”"; + } + + /* `registration.showNotification` where there is a service worker, because + `new Notification()` throws outright on Android Chrome -- so the plain + constructor alone would work on every desktop it was tested on and on no + phone at all. */ + function show(message, url) { + if (!wanted() || !("Notification" in window)) return; + if (Notification.permission !== "granted") return; + + /* `tag` collapses several into one: a browser left in the background for an + hour must not come back to a stack of them. */ + var options = { + body: message, + tag: "lembas-unread", + icon: "/static/img/icon-192.png", + data: { url: url }, + }; + if (navigator.serviceWorker && navigator.serviceWorker.ready) { + navigator.serviceWorker.ready + .then(function (registration) { registration.showNotification("LLeMbas", options); }) + .catch(function () { plain(options, url); }); + return; + } + plain(options, url); + } + + function plain(options, url) { + try { + var notification = new Notification("LLeMbas", options); + notification.onclick = function () { window.focus(); if (url) location.href = url; }; + } catch (error) { /* unsupported; the toast and the title still carry it */ } + } + + document.addEventListener("lembas:unread", function (event) { + var items = (event.detail && event.detail.items) || []; + if (!items.length) return; + + var message = describe(items); + if (window.lembas && window.lembas.notify) { + window.lembas.notify(message, { kind: "success", timeout: 6000 }); + } + if (document.hidden) { + rebase(); + pending += items.length; + retitle(); + show(message, items.length === 1 ? items[0].url : ""); + } + }); + + /* Asking. `requestPermission` must be called from a gesture, so every path to + it is a button somebody pressed -- a preference restored on load and acted + on is refused by the browser with nothing said anywhere. + + Shared by the Settings toggle and by the one-time offer below, because two + copies of "ask, then interpret the three answers" is two places for the + denied case to be got wrong. */ + function ask(onSettled) { + if (!("Notification" in window)) { + window.lembas.notify("This browser has no notifications to offer.", { kind: "error" }); + return; + } + if (Notification.permission === "denied") { + window.lembas.notify( + "Notifications are blocked for this site in your browser's own settings, " + + "which is the only place that can be undone.", + { kind: "error", timeout: 8000 } + ); + return; + } + Notification.requestPermission().then(function (result) { + if (result !== "granted") { + window.lembas.notify("Left off — nothing was changed."); + if (onSettled) onSettled(); + return; + } + setWanted(true); + if (onSettled) onSettled(); + subscribe().then(function (pushed) { + window.lembas.notify( + pushed + ? "Notifications on, including while LLeMbas is closed." + : "Notifications on while LLeMbas is open.", + { kind: "success", timeout: 6000 } + ); + }); + }); + } + + /* + Registering with the browser's push service. + + This is what makes a notification arrive with nothing of ours running -- + the poll above needs an open page, and the case worth notifying about is a + schedule firing at seven in the morning. + + Best effort, always. It needs a service worker (so HTTPS or localhost), a + push service the browser can reach, and a `PushManager` that some browsers + do not have; every one of those fails to "notifications while LLeMbas is + open", which still works. A permission granted and a subscription refused + must not read as a failure, because most of the feature is still there. + */ + function subscribe() { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + return Promise.resolve(false); + } + return fetch("/api/push/key") + .then(function (response) { return response.ok ? response.json() : null; }) + .then(function (data) { + if (!data || !data.key) return false; + return navigator.serviceWorker.ready.then(function (registration) { + return registration.pushManager.subscribe({ + /* Required, and not merely conventional: a browser refuses a + subscription that does not promise every push will be shown to + somebody. It is also why the worker's `push` handler always ends + in a notification unless a window is focused. */ + userVisibleOnly: true, + applicationServerKey: bytes(data.key), + }); + }).then(function (subscription) { + return fetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscription.toJSON()), + }).then(function (response) { return response.ok; }); + }); + }) + .catch(function () { return false; }); + } + + function unsubscribe() { + if (!("serviceWorker" in navigator)) return Promise.resolve(); + return navigator.serviceWorker.ready + .then(function (registration) { return registration.pushManager.getSubscription(); }) + .then(function (subscription) { + if (!subscription) return null; + var endpoint = subscription.endpoint; + return subscription.unsubscribe().then(function () { + return fetch("/api/push/unsubscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: endpoint }), + }); + }); + }) + .catch(function () { return null; }); + } + + /* base64url to bytes. `applicationServerKey` wants the raw 65-byte point and + will not take the string, and `atob` will not take base64url -- the two + substitutions and the padding are the whole of this. */ + function bytes(text) { + var padded = (text + "===".slice((text.length + 3) % 4)) + .replace(/-/g, "+") + .replace(/_/g, "/"); + var raw = window.atob(padded); + var out = new Uint8Array(raw.length); + for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; + } + + document.addEventListener("click", function (event) { + var button = event.target.closest("[data-notify-toggle]"); + if (!button) return; + event.preventDefault(); + + if (wanted()) { + setWanted(false); + // The registration goes too. Leaving it would mean this server kept + // sending to a browser that has been told to stop showing them, which is + // traffic to a third party for something switched off. + unsubscribe(); + paint(button); + return; + } + ask(function () { paint(button); }); + }); + + /* + Offering, once. + + The browser's own permission box cannot be called on page load and should + not be: it appears with no context, and a box somebody dismisses without + reading is a permission that can only be undone in browser settings they + will never find. So the offer is ours first -- a themed dialog that says + what the notifications are for -- and pressing its button is the gesture the + browser needs. + + Once, ever, per browser. "Not now" is recorded exactly as firmly as "yes": + an offer that comes back is the thing that makes people block a site to + silence it, and Settings has the switch for anybody who changes their mind. + */ + var ASKED_KEY = "lembas-notifications-asked"; + + function offer() { + if (!document.body || !document.body.dataset.authenticated) return; + if (!("Notification" in window) || Notification.permission !== "default") return; + try { + if (window.localStorage.getItem(ASKED_KEY)) return; + window.localStorage.setItem(ASKED_KEY, "1"); + } catch (error) { + return; // no way to remember having asked, so do not ask + } + if (!window.lembas || !window.lembas.confirm) return; + + window.lembas.confirm({ + title: "Notifications", + message: + "Let LLeMbas tell you when a reply, a report or a scheduled run arrives " + + "while you are looking at something else?", + confirmLabel: "Turn on", + cancelLabel: "Not now", + }).then(function (yes) { if (yes) ask(scan); }); + } + + function paint(button) { + var on = wanted() && "Notification" in window && Notification.permission === "granted"; + button.textContent = on ? "Turn off notifications" : "Turn on notifications"; + button.setAttribute("aria-pressed", on ? "true" : "false"); + var hint = document.querySelector("[data-notify-state]"); + if (!hint) return; + if (!("Notification" in window)) { + hint.textContent = "This browser has no notifications to offer."; + } else if (Notification.permission === "denied") { + hint.textContent = + "Blocked for this site in your browser's settings, which is the only " + + "place that can be undone."; + } else if (on) { + hint.textContent = "On in this browser. Nothing is shown while you are looking at the page."; + } else { + hint.textContent = "Off in this browser."; + } + } + + function scan() { + document.querySelectorAll("[data-notify-toggle]").forEach(paint); + } + + function start() { + scan(); + /* After the page has settled rather than during it: the offer is a dialog, + and one that appears while the shell is still being painted reads as an + error rather than as a question. */ + setTimeout(offer, 1500); + } + + document.addEventListener("DOMContentLoaded", start); + document.body && start(); + document.addEventListener("htmx:afterSettle", scan); +})(); /* A toast asked for by the server. diff --git a/src/lembas/web/templates/settings.html b/src/lembas/web/templates/settings.html index b2bc292..e7a8288 100644 --- a/src/lembas/web/templates/settings.html +++ b/src/lembas/web/templates/settings.html @@ -208,6 +208,33 @@ {% endif %} +
+ A reply, a filed report or a scheduled run arriving while you are + looking at something else. In the page always; from the browser + as well if you allow it here. +
+ {# A button rather than a checkbox, and that is not a style choice: + the browser refuses `requestPermission` unless it is called from + something somebody pressed. A checkbox restored on load and + acted upon would be silently declined. ui.js writes the label + and the line below it, because the true state lives in the + browser's own permission and not in anything rendered here. #} ++ Checking what this browser allows… +
++ Kept per browser, because the permission is. Nothing is shown + while you are looking at the page — the toast is there for that. +
+diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..e377ffe --- /dev/null +++ b/tests/test_push.py @@ -0,0 +1,406 @@ +"""Web Push: the encryption, the identity, and where an arrival is announced. + +The encryption is the part worth testing hardest, and for an unusual reason: +there is no way to tell from here whether it is right. A push service accepts +the POST and forwards the bytes without understanding them, so a wrong key +derivation is a notification that never appears on a device, with a 201 in the +log and nothing anywhere to read. So the record is decrypted back with an +independent implementation of the specification's other half — if the two agree +on a key derived through an ECDH neither of them chose, the derivation is right. +""" + +from __future__ import annotations + +import hmac +import json +from hashlib import sha256 + +import pytest +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand +from fastapi.testclient import TestClient +from sqlalchemy import select + +from lembas.db.models import PushSubscription, Report, User +from lembas.services import push as push_service +from lembas.services import reports as reports_service + + +# --- A browser, standing in for one -------------------------------------------- +class Browser: + """The client half of RFC 8291, written out rather than imported. + + This is the point: `services/push.py` and this class share no code, so a + payload that survives the round trip proves the derivation rather than + proving that one function is the inverse of itself. + """ + + def __init__(self) -> None: + self.private = ec.generate_private_key(ec.SECP256R1()) + self.auth = b"0123456789abcdef" # 16 bytes, as a browser generates + + @property + def p256dh(self) -> str: + return push_service.b64( + self.private.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + ) + + @property + def auth_b64(self) -> str: + return push_service.b64(self.auth) + + def open(self, record: bytes) -> bytes: + salt, record = record[:16], record[16:] + _size, record = record[:4], record[4:] + id_len, record = record[0], record[1:] + server_public, ciphertext = record[:id_len], record[id_len:] + + shared = self.private.exchange( + ec.ECDH(), + ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), server_public), + ) + key_info = ( + b"WebPush: info\x00" + + self.private.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + + server_public + ) + ikm = _hkdf(self.auth, shared, key_info, 32) + key = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16) + nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12) + + plain = AESGCM(key).decrypt(nonce, ciphertext, None) + assert plain[-1] == 0x02, "the last record must carry the 0x02 delimiter" + return plain[:-1] + + +def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes: + prk = hmac.new(salt, ikm, sha256).digest() + return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info).derive(prk) + + +# --- The payload --------------------------------------------------------------- +def test_a_record_decrypts_back_to_what_went_in(): + browser = Browser() + payload = json.dumps({"title": "Nightly build", "body": "It went green."}).encode() + + record = push_service.encrypt_payload( + payload, p256dh=browser.p256dh, auth=browser.auth_b64 + ) + + assert browser.open(record) == payload + + +def test_two_records_of_the_same_thing_differ(): + """A fresh ephemeral key and a fresh salt per record. Reusing either would + make identical notifications identical on the wire, which is a pattern the + push service can read without decrypting anything.""" + browser = Browser() + payload = b"same" + + first = push_service.encrypt_payload(payload, p256dh=browser.p256dh, auth=browser.auth_b64) + second = push_service.encrypt_payload(payload, p256dh=browser.p256dh, auth=browser.auth_b64) + + assert first != second + assert browser.open(first) == browser.open(second) == payload + + +def test_another_browsers_key_cannot_open_it(): + """The auth secret is bound into the first derivation, so a record is for + one subscription and not merely for one public key.""" + intended, other = Browser(), Browser() + record = push_service.encrypt_payload( + b"private", p256dh=intended.p256dh, auth=intended.auth_b64 + ) + + with pytest.raises(Exception): # noqa: B017 - any failure is the right one + other.open(record) + + +def test_the_header_block_is_laid_out_as_the_specification_says(): + """salt | record size | key id length | key | ciphertext. A parser that has + never seen our code reads it by position, so the positions are the + contract.""" + browser = Browser() + record = push_service.encrypt_payload(b"x", p256dh=browser.p256dh, auth=browser.auth_b64) + + assert len(record) > 16 + 4 + 1 + 65 + assert int.from_bytes(record[16:20], "big") == push_service.RECORD_SIZE + assert record[20] == 65 # an uncompressed P-256 point, always + + +# --- The identity --------------------------------------------------------------- +def test_the_keypair_is_generated_once_and_kept(db, registered): + """The public key is inside every subscription a browser holds, so a second + one silently invalidates all of them — notifications stop, with nothing + anywhere saying why.""" + first = push_service.public_key(db) + second = push_service.public_key(db) + + assert first and first == second + assert len(push_service.unb64(first)) == 65 + + +def test_the_private_half_is_encrypted_at_rest(db, registered): + from lembas.services import settings_store + + push_service.keys(db) + stored = str(settings_store.get(db, push_service.SETTING_PRIVATE)) + + assert stored + assert "BEGIN PRIVATE KEY" not in stored + + +def test_the_token_is_signed_for_the_push_service_it_is_going_to(db, registered): + """`aud` is the origin of the endpoint. A token minted for one push service + and sent to another is refused with a 401, which looks like a broken key.""" + token = push_service._jwt( + push_service.keys(db).private, "https://fcm.googleapis.com", "https://example.test" + ) + header, claims, signature = token.split(".") + + assert json.loads(push_service.unb64(header))["alg"] == "ES256" + assert json.loads(push_service.unb64(claims))["aud"] == "https://fcm.googleapis.com" + # Raw r||s, not DER: a DER signature is the same length most of the time, + # which is why this is asserted rather than assumed. + assert len(push_service.unb64(signature)) == 64 + + +# --- Registering ---------------------------------------------------------------- +def _subscribe(client: TestClient, endpoint: str = "https://push.example/abc"): + return client.post( + "/api/push/subscribe", + json={"endpoint": endpoint, "keys": {"p256dh": "BAA" + "A" * 84, "auth": "AAAAAAAA"}}, + ) + + +def test_a_browser_registers_and_can_be_forgotten(client: TestClient, db, registered): + assert _subscribe(client).status_code == 204 + row = db.scalars(select(PushSubscription)).one() + assert row.endpoint == "https://push.example/abc" + + response = client.post("/api/push/unsubscribe", json={"endpoint": row.endpoint}) + assert response.status_code == 204 + assert db.scalars(select(PushSubscription)).all() == [] + + +def test_registering_twice_is_one_row(client: TestClient, db, registered): + """A browser that re-subscribes hands back the same endpoint, and two rows + would be two notifications for one arrival.""" + _subscribe(client) + _subscribe(client) + + assert len(db.scalars(select(PushSubscription)).all()) == 1 + + +def test_an_endpoint_that_is_not_https_is_refused(client: TestClient, db, registered): + response = client.post( + "/api/push/subscribe", + json={"endpoint": "http://push.example/abc", "keys": {"p256dh": "x", "auth": "y"}}, + ) + assert response.status_code == 400 + + +def test_forgetting_somebody_elses_registration_does_nothing(client: TestClient, db, registered): + from lembas.security.passwords import hash_password + + _subscribe(client) + other = User(email="other@example.test", name="O", password_hash=hash_password("x" * 12)) + db.add(other) + db.commit() + row = db.scalars(select(PushSubscription)).one() + row.user_id = other.id + db.commit() + + client.post("/api/push/unsubscribe", json={"endpoint": row.endpoint}) + + assert db.scalars(select(PushSubscription)).all() != [] + + +# --- Where an arrival is announced ---------------------------------------------- +async def test_filing_a_report_announces_it(db, registered, monkeypatch): + """At the arrival rather than from the poll, which is the whole point: the + poll needs an open page and this is the case where there is not one.""" + said: list[dict] = [] + monkeypatch.setattr( + push_service, + "announce_later", + lambda user_id, **kwargs: said.append({"user": user_id, **kwargs}), + ) + + owner = db.scalars(select(User)).first() + reports_service.create(db, owner=owner, title="Nightly", body="It went green.") + + assert len(said) == 1 + assert said[0]["title"] == "Nightly" + assert said[0]["url"].startswith("/reports/") + + +async def test_a_report_the_reader_filed_themselves_is_not_announced( + db, registered, monkeypatch +): + """`unread` is what says it is news. One filed with it off was written by + the person looking at the screen.""" + said: list[dict] = [] + monkeypatch.setattr(push_service, "announce_later", lambda *a, **k: said.append(k)) + + owner = db.scalars(select(User)).first() + reports_service.create(db, owner=owner, title="Mine", body="x", unread=False) + + assert said == [] + + +async def test_announcing_with_no_registered_browser_is_a_no_op(db, registered): + owner = db.scalars(select(User)).first() + assert await push_service.announce(db, owner, title="t", body="b") == 0 + + +async def test_a_gone_subscription_is_deleted_rather_than_retried( + db, registered, mock_http +): + """404 and 410 are the normal end of a subscription's life — uninstalled, + cleared, permission revoked. Keeping it would mean retrying for ever + against an address that will never answer.""" + import httpx + + db.add( + PushSubscription( + user_id=db.scalars(select(User)).first().id, + endpoint="https://push.example/gone", + p256dh=Browser().p256dh, + auth_secret=push_service.b64(b"0123456789abcdef"), + ) + ) + db.commit() + row = db.scalars(select(PushSubscription)).one() + + mock_http(lambda request: httpx.Response(410)) + + assert await push_service.send_one(db, row, {"title": "x"}) is False + assert db.scalars(select(PushSubscription)).all() == [] + + +async def test_a_refusal_that_is_not_gone_keeps_the_subscription(db, registered, mock_http): + """A push service having a bad hour is not a reason to lose somebody's + registration.""" + import httpx + + db.add( + PushSubscription( + user_id=db.scalars(select(User)).first().id, + endpoint="https://push.example/busy", + p256dh=Browser().p256dh, + auth_secret=push_service.b64(b"0123456789abcdef"), + ) + ) + db.commit() + row = db.scalars(select(PushSubscription)).one() + + mock_http(lambda request: httpx.Response(503, text="try later")) + + assert await push_service.send_one(db, row, {"title": "x"}) is False + kept = db.scalars(select(PushSubscription)).one() + assert "503" in kept.last_error + + +async def test_a_send_carries_the_two_headers_a_push_service_requires( + db, registered, mock_http +): + import httpx + + seen: dict = {} + + db.add( + PushSubscription( + user_id=db.scalars(select(User)).first().id, + endpoint="https://push.example/ok", + p256dh=Browser().p256dh, + auth_secret=push_service.b64(b"0123456789abcdef"), + ) + ) + db.commit() + row = db.scalars(select(PushSubscription)).one() + + def handler(request): + seen.update(dict(request.headers)) + return httpx.Response(201) + + mock_http(handler) + + assert await push_service.send_one(db, row, {"title": "x"}) is True + assert seen["content-encoding"] == "aes128gcm" + assert seen["authorization"].startswith("vapid t=") + assert ", k=" in seen["authorization"] + + +def test_a_report_row_remembers_having_been_announced(db, registered): + """The dot may be shown for as long as something is unread; the toast fires + once. Reading those apart is what stops the poll interrupting somebody every + ten seconds with the same report until they open it.""" + owner = db.scalars(select(User)).first() + reports_service.create(db, owner=owner, title="One", body="x") + + first = reports_service.unannounced(db, owner) + assert len(first) == 1 + + first[0].unread_notified = True + db.commit() + assert reports_service.unannounced(db, owner) == [] + # And it is still unread, so the dot stays. + assert db.scalars(select(Report)).one().unread + + +# --- The poll announces everything, not only chats ------------------------------- +def test_the_poll_announces_a_report(client: TestClient, db, registered): + """The dots have covered Reports since the section existed; the + announcement did not. So a scheduled run that filed one lit a dot in a + corner and said nothing at all — which is precisely the arrival nobody is + watching for.""" + owner = db.scalars(select(User)).first() + reports_service.create(db, owner=owner, title="Nightly build", body="green") + + response = client.get("/api/chats/unread") + + trigger = json.loads(response.headers["HX-Trigger"]) + items = trigger["lembas:unread"]["items"] + assert any(i["kind"] == "report" and i["title"] == "Nightly build" for i in items) + # And where to go, because a browser notification is a thing you click. + assert all(i["url"] for i in items) + + +def test_a_report_is_announced_once(client: TestClient, db, registered): + """A poll that said the same thing every ten seconds until somebody opened + it is the shape of notification nobody leaves switched on.""" + owner = db.scalars(select(User)).first() + reports_service.create(db, owner=owner, title="Nightly", body="x") + + assert "HX-Trigger" in client.get("/api/chats/unread").headers + assert "HX-Trigger" not in client.get("/api/chats/unread").headers + # Still unread, so the dot is still there to be cleared by reading it. + assert db.scalars(select(Report)).one().unread + + +def test_the_poll_announces_the_messages_conversation(client: TestClient, db, registered): + from lembas.db.models import Chat + from lembas.services import messages as messages_service + + owner = db.scalars(select(User)).first() + conversation = messages_service.for_user(db, owner) + conversation.unread = True + conversation.unread_notified = False + db.commit() + + response = client.get("/api/chats/unread") + + items = json.loads(response.headers["HX-Trigger"])["lembas:unread"]["items"] + assert any(i["kind"] == "message" and i["url"] == "/messages" for i in items) + # And it is recorded as said, so the next tick does not say it again. + db.expire_all() + assert db.get(Chat, conversation.id).unread_notified diff --git a/tests/test_ui_js.py b/tests/test_ui_js.py index 6d68753..3a4081d 100644 --- a/tests/test_ui_js.py +++ b/tests/test_ui_js.py @@ -254,3 +254,47 @@ def test_the_file_picker_asks_for_files(): # Directories stay a step in file mode, or a file two folders down is # unreachable. assert app.count("[data-dir-open]") >= 2 + + +def test_a_notification_is_never_shown_to_somebody_looking_at_the_page(): + """Three channels carry one arrival and they must not all fire at once: the + toast is for somebody watching, the tab-title count for somebody in another + tab, and the system notification for somebody elsewhere entirely. + + The service worker is the only place that can tell -- the server cannot see + whether a window is focused, and the page cannot see a push it did not + receive. Driven under a DOM stub; what is pinned here is that both halves + of the decision exist. + """ + worker = (ROOT / "web/static/js/sw.js").read_text(encoding="utf-8") + + # The page half: nothing but a toast while it is being looked at. + assert "document.hidden" in SOURCE + # The worker half: no notification when one of its own windows has focus. + assert "clients[i].focused" in worker + assert "showNotification" in worker + + +def test_the_tab_title_count_re_reads_its_base(): + """The title is rewritten by navigation and by a rename arriving out of + band, so a base captured once would pin the old name until a reload.""" + assert "replace(/^\\(\\d+\\)\\s*/" in SOURCE + + +def test_permission_is_only_ever_asked_from_a_gesture(): + """`requestPermission` is refused outside one, silently. A checkbox restored + on load and acted upon would look exactly like a switch that does nothing, + which is the failure this codebase keeps cataloguing.""" + settings = (TEMPLATES / "settings.html").read_text(encoding="utf-8") + + # A button, not an input whose `change` writes. + assert "data-notify-toggle" in settings + assert '