aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/notifications.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/push.py277
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/push.js68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/webpush.py194
21 files changed, 783 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
index e4bac2e..128c0d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
@@ -26,8 +26,9 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.push import push_notification
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import GroupMember, Notification, User
+from meshbay_hub.db.models import GroupMember, Notification, User, UserPreference
router = APIRouter(prefix="/v1/notifications", tags=["notifications"])
@@ -157,9 +158,23 @@ async def create_notification(
conversation is a single line saying when it last spoke rather than forty
saying that it spoke.
- Returns None when the person muted this group: the point of muting is that
- nothing is created, not that something is created and hidden.
+ Returns None when the person muted this group, or turned every notification
+ off: the point of muting is that nothing is created, not that something is
+ created and hidden — and nothing created is nothing pushed to a phone.
+
+ The account-wide switch used to be read by the interface alone, which hid
+ the list while rows went on accumulating; with a phone that is told about
+ each row, a switch only the interface honours is a switch that does nothing.
"""
+ disabled = await db.execute(
+ select(UserPreference.value).where(
+ UserPreference.user_id == user_id,
+ UserPreference.key == "notifications_disabled",
+ )
+ )
+ if disabled.scalar() == "true":
+ return None
+
if group_id is not None:
muted = await db.execute(
select(GroupMember.muted).where(
@@ -185,6 +200,7 @@ async def create_notification(
existing.read = False
existing.created_at = datetime.now(UTC)
await db.flush()
+ await push_notification(db, existing)
return existing
notif = Notification(
@@ -193,4 +209,5 @@ async def create_notification(
)
db.add(notif)
await db.flush()
+ await push_notification(db, notif)
return notif
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/push.py b/packages/meshbay-hub/src/meshbay_hub/api/push.py
new file mode 100644
index 0000000..0c4a411
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/push.py
@@ -0,0 +1,277 @@
+"""
+Notifications on a phone — /v1/push/*: pushed when it can be, fetched when not.
+
+A phone registers once and gets a row here. **With a UnifiedPush distributor**
+it gives an endpoint and a P-256 key, and every notification
+`create_notification` lets through is sent there, encrypted to the phone
+(`webpush.py`). **Without one** — nothing to install is the default — the row has
+no endpoint, and the phone fetches what is new with `POST /v1/push/poll` every
+quarter of an hour or so. Both are the same rows and the same payload, so a phone
+can move between them (a distributor installed, removed, refusing) without the
+hub caring which.
+
+**Nothing reaches a phone that was not created**: a muted group and an account
+with every notification turned off stop at `create_notification`, before this
+module is reached, so the two switches the person sees are the only two there are.
+
+The poll is authenticated by a secret issued with the row, not by a session. It
+reads notification lines and nothing else, so a phone running in the background
+holds no token that could do anything more — and a sign-out, which deletes the
+row, ends it.
+
+Who pays (§13.5b): a member's chat costs every other member's phones a push.
+That fan-out is already bounded where it starts — `chat_notify` is budgeted per
+node — and here a conversation reaches each phone at most once per
+`CHAT_COALESCE` seconds: the phone shows one line per group, so the pushes in
+between would only have replaced it. An account holds `MAX_SUBSCRIPTIONS` rows
+at most, because each is one outbound request per notification; a row is polled
+at most once per `POLL_MIN_INTERVAL`.
+"""
+
+import asyncio
+import hashlib
+import hmac
+import logging
+import math
+import secrets
+import time
+from datetime import UTC, datetime
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub import webpush
+from meshbay_hub.api.deps import require_user_scope
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Notification, PushSubscription, User
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/v1/push", tags=["push"])
+
+MAX_SUBSCRIPTIONS = 10
+CHAT_COALESCE = 30.0
+_COALESCE_ENTRIES = 10_000
+POLL_MIN_INTERVAL = 60.0
+POLL_LIMIT = 20
+
+# Strong references: asyncio holds a task weakly, and a collected one is a push
+# that silently never went (CLAUDE.md, "a background task nobody holds").
+_tasks: set[asyncio.Task] = set()
+_last_chat: dict[tuple[str, str], float] = {}
+_last_poll: dict[str, float] = {}
+# Replaced by the tests; the real one never raises.
+_send = webpush.send
+
+
+class SubscriptionIn(BaseModel):
+ # The row this phone already has, to update rather than add one: a phone
+ # with no endpoint has nothing else to be recognised by.
+ id: str | None = Field(default=None, max_length=36)
+ endpoint: str | None = Field(default=None, max_length=webpush.MAX_ENDPOINT)
+ # Lengths bounded before decoding: base64 decoding skips characters outside
+ # its alphabet, so an unbounded string could still decode to 65 bytes.
+ p256dh: str | None = Field(default=None, max_length=128)
+ auth: str | None = Field(default=None, max_length=32)
+
+
+def _hash(secret: str) -> str:
+ return hashlib.sha256(secret.encode()).hexdigest()
+
+
+def _payload(notif: Notification) -> dict:
+ """What a phone is told, pushed or fetched: the hub's own line, never a message."""
+ return {
+ "v": 1,
+ "id": notif.id,
+ "kind": notif.kind,
+ "title": notif.title,
+ "link": notif.link,
+ "group_id": notif.group_id,
+ "created_at": _iso(notif.created_at),
+ }
+
+
+def _iso(at: datetime) -> str:
+ # SQLite hands back naive datetimes; every one stored here is UTC.
+ return (at if at.tzinfo else at.replace(tzinfo=UTC)).isoformat()
+
+
+@router.post("/subscriptions")
+async def subscribe(
+ body: SubscriptionIn,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Register this phone, or update its row: with an endpoint and keys when it
+ has a push distributor, without them when it will fetch instead.
+
+ Answers the row's id, a fresh secret for `POST /v1/push/poll` (the previous
+ one stops working) and the hub's time, from which the phone counts what is
+ new — what was there before it registered is not news.
+ """
+ if body.endpoint is not None:
+ if body.p256dh is None or body.auth is None:
+ raise HTTPException(status_code=422, detail="an endpoint needs its keys")
+ try:
+ webpush.check_endpoint(body.endpoint)
+ webpush.check_keys(body.p256dh, body.auth)
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
+
+ sub = None
+ if body.id is not None:
+ sub = await db.get(PushSubscription, body.id)
+ if sub is not None and sub.user_id != current_user.id:
+ sub = None
+ if body.endpoint is not None:
+ same = (await db.execute(
+ select(PushSubscription).where(
+ PushSubscription.user_id == current_user.id,
+ PushSubscription.endpoint == body.endpoint))).scalar_one_or_none()
+ if sub is None:
+ sub = same
+ elif same is not None and same.id != sub.id:
+ # The endpoint moved to this row; the old one would only repeat it.
+ await db.delete(same)
+ await db.flush()
+ if sub is None:
+ held = (await db.execute(
+ select(func.count()).select_from(PushSubscription)
+ .where(PushSubscription.user_id == current_user.id))).scalar() or 0
+ if held >= MAX_SUBSCRIPTIONS:
+ raise HTTPException(status_code=429, detail="Too many push subscriptions")
+ sub = PushSubscription(user_id=current_user.id)
+ db.add(sub)
+ secret = secrets.token_urlsafe(32)
+ sub.endpoint, sub.p256dh, sub.auth = body.endpoint, body.p256dh, body.auth
+ sub.poll_hash = _hash(secret)
+ await db.commit()
+ return {"id": sub.id, "poll_secret": secret, "now": datetime.now(UTC).isoformat()}
+
+
+class PollIn(BaseModel):
+ id: str = Field(max_length=36)
+ secret: str = Field(max_length=64)
+ since: datetime
+
+
+@router.post("/poll")
+async def poll(body: PollIn, db: AsyncSession = Depends(get_db)):
+ """
+ What is new for this phone since `since`: the same payloads a push carries,
+ oldest first, at most twenty.
+
+ Authenticated by the row's secret rather than a session, so what a phone
+ keeps for running in the background reads notification lines and nothing
+ else. A wrong secret and an unknown row answer the same 404.
+ """
+ sub = await db.get(PushSubscription, body.id)
+ if (sub is None or sub.poll_hash is None
+ or not hmac.compare_digest(sub.poll_hash, _hash(body.secret))):
+ raise HTTPException(status_code=404, detail="Subscription not found")
+ now = time.monotonic()
+ last = _last_poll.get(sub.id)
+ if last is not None and now - last < POLL_MIN_INTERVAL:
+ raise HTTPException(status_code=429, detail="Polled too often",
+ headers={"Retry-After": str(int(POLL_MIN_INTERVAL - (now - last)) + 1)})
+ if len(_last_poll) >= _COALESCE_ENTRIES:
+ for k in [k for k, at in _last_poll.items() if now - at >= POLL_MIN_INTERVAL]:
+ del _last_poll[k]
+ _last_poll[sub.id] = now
+
+ since = body.since if body.since.tzinfo else body.since.replace(tzinfo=UTC)
+ rows = (await db.execute(
+ select(Notification).where(
+ Notification.user_id == sub.user_id,
+ Notification.created_at > since.astimezone(UTC),
+ ).order_by(Notification.created_at.desc()).limit(POLL_LIMIT)
+ )).scalars().all()
+ return {"notifications": [_payload(n) for n in reversed(rows)]}
+
+
+@router.delete("/subscriptions/{subscription_id}")
+async def unsubscribe(
+ subscription_id: str,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Stop telling one phone anything: turned off there, or signed out of."""
+ sub = await db.get(PushSubscription, subscription_id)
+ if sub is None or sub.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Subscription not found")
+ await db.delete(sub)
+ await db.commit()
+ return {"status": "ok"}
+
+
+def _coalesced(sub_id: str, group_id: str, now: float) -> bool:
+ key = (sub_id, group_id)
+ if now - _last_chat.get(key, -math.inf) < CHAT_COALESCE:
+ return True
+ if len(_last_chat) >= _COALESCE_ENTRIES:
+ for k in [k for k, at in _last_chat.items() if now - at >= CHAT_COALESCE]:
+ del _last_chat[k]
+ _last_chat[key] = now
+ return False
+
+
+async def push_notification(db: AsyncSession, notif: Notification) -> None:
+ """
+ Send `notif` to the person's phones, off the caller's path.
+
+ Called by `create_notification` once the row exists, inside the caller's
+ transaction: the subscriptions are read there, the requests leave in a task
+ of their own, so a slow push server delays nobody's request.
+ """
+ subs = (await db.execute(
+ select(PushSubscription).where(PushSubscription.user_id == notif.user_id,
+ PushSubscription.endpoint.is_not(None))
+ )).scalars().all()
+ if not subs:
+ return
+ payload = _payload(notif)
+ now = time.monotonic()
+ targets = [
+ (s.id, webpush.Target(s.endpoint, s.p256dh, s.auth)) for s in subs
+ if not (notif.kind == "chat_message" and notif.group_id
+ and _coalesced(s.id, notif.group_id, now))
+ ]
+ if not targets:
+ return
+ ttl = webpush.TTL_CHAT if notif.kind == "chat_message" else webpush.TTL_OTHER
+ task = asyncio.get_running_loop().create_task(_deliver(targets, payload, ttl))
+ _tasks.add(task)
+ task.add_done_callback(_tasks.discard)
+
+
+async def _deliver(targets: list[tuple[str, webpush.Target]], payload: dict,
+ ttl: int) -> None:
+ results = await asyncio.gather(*(_send(t, payload, ttl=ttl) for _, t in targets),
+ return_exceptions=True)
+ gone = [sid for (sid, _), r in zip(targets, results, strict=True) if r == webpush.GONE]
+ if not gone:
+ return
+ # The distributor dropped the registration: pushing there would cost a
+ # request per notification for ever and reach nothing. The row stays, without
+ # its endpoint — the phone still fetches, and registers again when it can.
+ try:
+ from meshbay_hub.db.engine import get_session_factory
+ async with get_session_factory()() as db:
+ await db.execute(
+ update(PushSubscription).where(PushSubscription.id.in_(gone))
+ .values(endpoint=None, p256dh=None, auth=None))
+ await db.commit()
+ except Exception as e:
+ log.warning("Could not drop %d gone push subscription(s): %s", len(gone), e)
+
+
+async def drain() -> None:
+ """Wait for every push in flight — for the tests, and for a clean shutdown."""
+ # Done tasks leave the set from a callback the loop has not run yet, and
+ # awaiting a finished gather never yields to it: wait on the unfinished only.
+ while pending := [t for t in _tasks if not t.done()]:
+ await asyncio.gather(*pending, return_exceptions=True)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 795a902..130240e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -46,6 +46,7 @@ from meshbay_hub.db.models import (
KnownBrowser,
Node,
Notification,
+ PushSubscription,
RefreshToken,
User,
UserDevice,
@@ -1361,6 +1362,7 @@ async def password_reset(
.values(revoked=True))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
+ await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id))
# A code sent to the address on file is a stronger proof than a passphrase,
# and it is the way out of a lockout somebody else caused.
await login_throttle.clear(db, user.username)
@@ -1579,6 +1581,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(Node).where(Node.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
+ await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
# Links this account issued for a group it no longer owns; the ones for its
# own groups went with them above. A used link keeps pointing at the
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index ca05fd8..2ad71bd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -30,6 +30,7 @@ from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.nodes import router as nodes_router
from meshbay_hub.api.notifications import router as notifications_router
+from meshbay_hub.api.push import router as push_router
from meshbay_hub.api.revocation import router as revocation_router
from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.users import router as users_router
@@ -233,6 +234,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(signaling_router)
app.include_router(admin_router)
app.include_router(notifications_router)
+ app.include_router(push_router)
app.include_router(webapp_router)
from starlette.staticfiles import StaticFiles
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py
new file mode 100644
index 0000000..4311448
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py
@@ -0,0 +1,35 @@
+"""phones told about notifications: a Web Push endpoint, or a poll secret
+
+Revision ID: e7f8a9b0c1d2
+Revises: d4e5f6a7b8ca
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e7f8a9b0c1d2"
+down_revision: str | Sequence[str] | None = "d4e5f6a7b8ca"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "push_subscriptions",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("endpoint", sa.String(1024), nullable=True),
+ sa.Column("p256dh", sa.String(128), nullable=True),
+ sa.Column("auth", sa.String(32), nullable=True),
+ sa.Column("poll_hash", sa.String(64), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True)),
+ )
+ op.create_index("ix_push_subscriptions_user_endpoint", "push_subscriptions",
+ ["user_id", "endpoint"], unique=True)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_push_subscriptions_user_endpoint", table_name="push_subscriptions")
+ op.drop_table("push_subscriptions")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index dbc0f10..7291485 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -398,6 +398,34 @@ class ContentReview(Base):
decided_by: Mapped[str | None] = mapped_column(String(64))
+class PushSubscription(Base):
+ """A phone told about this account's notifications (§11.3): pushed to its
+ Web Push endpoint when it has one, fetched with its poll secret when not.
+
+ The endpoint is a capability — whoever holds the URL can wake the phone —
+ and the keys are what the hub encrypts to, so the push server relays bytes
+ it cannot read. One account holds a handful at most
+ (`api/push.MAX_SUBSCRIPTIONS`): the rows are shared, and every notification
+ costs one outbound request per row.
+ """
+
+ __tablename__ = "push_subscriptions"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ # All three empty for a phone with no push distributor, which fetches instead.
+ endpoint: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+ p256dh: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ auth: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ # sha256 of the secret `POST /v1/push/poll` is answered for; never the secret.
+ poll_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+ __table_args__ = (
+ Index("ix_push_subscriptions_user_endpoint", "user_id", "endpoint", unique=True),
+ )
+
+
class UserPreference(Base):
__tablename__ = "user_preferences"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index b9466d0..2254a11 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -31,6 +31,7 @@ import {
} from './playlists.js';
import { IndexingDock } from './index-dock.js';
import { SettingsPage } from './settings-page.js';
+import { syncPush, disablePush } from './push.js';
import { ProfilePage } from './profile-page.js';
import { ExplorePage } from './explore-page.js';
import { GroupName } from './group-name.js';
@@ -1042,6 +1043,7 @@ function App() {
.catch(() => {});
refreshNodeKey();
fetchNotifications();
+ syncPush(user).catch(() => {});
}, [user]);
// The node this application ships, set up, started and linked for whoever
@@ -1233,6 +1235,13 @@ function App() {
// Navigating away leaves transfers running; signing out does not. They
// are moving data on tokens that are about to stop being ours.
transfers.reset();
+ // This phone stops being told about the account it is leaving. Its
+ // access token is still good for that request; the refresh token's
+ // revocation below does not touch it.
+ // The stored session, read now: React's copy can be a renewal behind,
+ // and once the session is cleared nothing could renew it.
+ disablePush(user && { ...user, token: (loadAuth() || {}).token || user.token })
+ .catch(() => {});
// Revoked on the hub too, so a copy of the refresh token is worth
// nothing. Read before the next line clears it.
logoutOnHub();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 4dff605..d8b7b5c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -436,6 +436,15 @@ export default {
'settings.email_verified': 'E-Mail-Adresse erfolgreich geändert.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Benachrichtigungen auf diesem Telefon',
+ 'settings.push_off': 'Aus. Einschalten, um bei geschlossenem MeshBay über neue Nachrichten und Einladungen informiert zu werden.',
+ 'settings.push_pending': 'Warte auf die Antwort des Verteilers…',
+ 'settings.push_blocked': 'Android blockiert die Benachrichtigungen von MeshBay: Erlauben Sie sie in den Systemeinstellungen.',
+ 'settings.push_ready': 'An. Neue Benachrichtigungen erreichen dieses Telefon über den Verteiler, verschlüsselt, sodass er sie nicht lesen kann.',
+ 'settings.push_poll': 'An. Dieses Telefon sieht etwa alle fünfzehn Minuten nach neuen Benachrichtigungen. Damit sie sofort ankommen, können Sie eine UnifiedPush-Verteiler-App wie ntfy installieren (optional).',
+ 'settings.push_global_off': 'Solange alle Benachrichtigungen ausgeschaltet sind, wird nichts gesendet.',
+ 'push.channel_chat': 'Nachrichten',
+ 'push.channel_other': 'Einladungen und Konto',
'settings.defaults': 'Standardwerte',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 90c043e..a4a8215 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -433,6 +433,15 @@ export default {
'settings.email_verified': 'Email address changed successfully.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Notifications on this phone',
+ 'settings.push_off': 'Off. Turn on to be told about new messages and invitations while MeshBay is closed.',
+ 'settings.push_pending': 'Waiting for the distributor’s answer…',
+ 'settings.push_blocked': 'Android is blocking MeshBay’s notifications: allow them in the system settings.',
+ 'settings.push_ready': 'On. New notifications reach this phone through the distributor, encrypted so it cannot read them.',
+ 'settings.push_poll': 'On. This phone checks for new notifications about every fifteen minutes. For them to arrive at once, you can install a UnifiedPush distributor app such as ntfy (optional).',
+ 'settings.push_global_off': 'Nothing is sent while every notification is turned off.',
+ 'push.channel_chat': 'Messages',
+ 'push.channel_other': 'Invitations and account',
'settings.defaults': 'Defaults',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 7b46b17..78160d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -434,6 +434,15 @@ export default {
'settings.email_verified': 'Dirección de correo cambiada con éxito.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Notificaciones en este teléfono',
+ 'settings.push_off': 'Desactivadas. Actívalas para enterarte de nuevos mensajes e invitaciones con MeshBay cerrado.',
+ 'settings.push_pending': 'Esperando la respuesta del distribuidor…',
+ 'settings.push_blocked': 'Android bloquea las notificaciones de MeshBay: permítelas en los ajustes del sistema.',
+ 'settings.push_ready': 'Activadas. Las nuevas notificaciones llegan a este teléfono a través del distribuidor, cifradas para que no pueda leerlas.',
+ 'settings.push_poll': 'Activadas. Este teléfono busca notificaciones nuevas cada quince minutos aproximadamente. Para recibirlas al instante, puedes instalar una app distribuidora de UnifiedPush como ntfy (opcional).',
+ 'settings.push_global_off': 'No se envía nada mientras todas las notificaciones estén desactivadas.',
+ 'push.channel_chat': 'Mensajes',
+ 'push.channel_other': 'Invitaciones y cuenta',
'settings.defaults': 'Valores predeterminados',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index d3f9200..1fbb6cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -435,6 +435,15 @@ export default {
'settings.email_verified': 'Adresse e-mail modifiée avec succès.',
'settings.notif_global_disable': 'Désactiver toutes les notifications',
'settings.notif_global_hint': 'Quand activé, aucune notification n\x27est créée pour aucun groupe.',
+ 'settings.push_label': 'Notifications sur ce téléphone',
+ 'settings.push_off': 'Désactivées. Activez-les pour être prévenu des nouveaux messages et invitations quand MeshBay est fermé.',
+ 'settings.push_pending': 'En attente de la réponse du distributeur…',
+ 'settings.push_blocked': 'Android bloque les notifications de MeshBay : autorisez-les dans les paramètres du système.',
+ 'settings.push_ready': 'Activées. Les nouvelles notifications arrivent sur ce téléphone par le distributeur, chiffrées pour qu’il ne puisse pas les lire.',
+ 'settings.push_poll': 'Activées. Ce téléphone vérifie les nouvelles notifications environ toutes les quinze minutes. Pour les recevoir immédiatement, vous pouvez installer une application de distribution UnifiedPush comme ntfy (facultatif).',
+ 'settings.push_global_off': 'Rien n’est envoyé tant que toutes les notifications sont désactivées.',
+ 'push.channel_chat': 'Messages',
+ 'push.channel_other': 'Invitations et compte',
'settings.defaults': 'Valeurs par défaut',
'settings.default_tab': 'Onglet par défaut',
'settings.default_tab_hint': 'L\'onglet qui s\'ouvre en premier quand vous entrez dans un groupe.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 715738e..250e769 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -435,6 +435,15 @@ export default {
'settings.email_verified': 'Indirizzo e-mail modificato con successo.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Notifiche su questo telefono',
+ 'settings.push_off': 'Disattivate. Attivale per essere avvisato di nuovi messaggi e inviti quando MeshBay è chiuso.',
+ 'settings.push_pending': 'In attesa della risposta del distributore…',
+ 'settings.push_blocked': 'Android blocca le notifiche di MeshBay: consentile nelle impostazioni di sistema.',
+ 'settings.push_ready': 'Attivate. Le nuove notifiche arrivano su questo telefono tramite il distributore, cifrate in modo che non possa leggerle.',
+ 'settings.push_poll': 'Attivate. Questo telefono controlla le nuove notifiche circa ogni quindici minuti. Per riceverle subito, puoi installare un’app distributore UnifiedPush come ntfy (facoltativo).',
+ 'settings.push_global_off': 'Non viene inviato nulla finché tutte le notifiche sono disattivate.',
+ 'push.channel_chat': 'Messaggi',
+ 'push.channel_other': 'Inviti e account',
'settings.defaults': 'Valori predefiniti',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 23358c2..ad4ba25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -432,6 +432,15 @@ export default {
'settings.email_verified': 'メールアドレスが正常に変更されました。',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'この端末の通知',
+ 'settings.push_off': 'オフ。オンにすると、MeshBay を閉じていても新着メッセージや招待が通知されます。',
+ 'settings.push_pending': 'ディストリビューターの応答を待っています…',
+ 'settings.push_blocked': 'Android が MeshBay の通知をブロックしています。システム設定で許可してください。',
+ 'settings.push_ready': 'オン。新しい通知はディストリビューター経由でこの端末に届きます。暗号化されているため、ディストリビューターは内容を読めません。',
+ 'settings.push_poll': 'オン。この端末は約15分ごとに新しい通知を確認します。すぐに受け取りたい場合は、ntfy などの UnifiedPush ディストリビューターアプリをインストールできます(任意)。',
+ 'settings.push_global_off': 'すべての通知がオフの間は何も送信されません。',
+ 'push.channel_chat': 'メッセージ',
+ 'push.channel_other': '招待とアカウント',
'settings.defaults': 'デフォルト',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 5e53084..78bada9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -436,6 +436,15 @@ export default {
'settings.email_verified': 'E-mailadres succesvol gewijzigd.',
'settings.notif_global_disable': 'Alle meldingen uitschakelen',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Meldingen op deze telefoon',
+ 'settings.push_off': 'Uit. Zet aan om over nieuwe berichten en uitnodigingen te horen terwijl MeshBay gesloten is.',
+ 'settings.push_pending': 'Wachten op het antwoord van de distributor…',
+ 'settings.push_blocked': 'Android blokkeert de meldingen van MeshBay: sta ze toe in de systeeminstellingen.',
+ 'settings.push_ready': 'Aan. Nieuwe meldingen bereiken deze telefoon via de distributor, versleuteld zodat die ze niet kan lezen.',
+ 'settings.push_poll': 'Aan. Deze telefoon kijkt ongeveer elk kwartier of er nieuwe meldingen zijn. Wil je ze meteen ontvangen, installeer dan een UnifiedPush-distributor-app zoals ntfy (optioneel).',
+ 'settings.push_global_off': 'Er wordt niets verzonden zolang alle meldingen uit staan.',
+ 'push.channel_chat': 'Berichten',
+ 'push.channel_other': 'Uitnodigingen en account',
'settings.defaults': 'Standaardwaarden',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 5d6d852..fd26974 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -447,6 +447,15 @@ export default {
'settings.email_verified': 'Adres e-mail został pomyślnie zmieniony.',
'settings.notif_global_disable': 'Wyłącz wszystkie powiadomienia',
'settings.notif_global_hint': 'Po włączeniu nie będą tworzone żadne powiadomienia dla żadnej grupy.',
+ 'settings.push_label': 'Powiadomienia na tym telefonie',
+ 'settings.push_off': 'Wyłączone. Włącz, aby dowiadywać się o nowych wiadomościach i zaproszeniach, gdy MeshBay jest zamknięty.',
+ 'settings.push_pending': 'Oczekiwanie na odpowiedź dystrybutora…',
+ 'settings.push_blocked': 'Android blokuje powiadomienia MeshBay: zezwól na nie w ustawieniach systemu.',
+ 'settings.push_ready': 'Włączone. Nowe powiadomienia docierają na ten telefon przez dystrybutora, zaszyfrowane tak, że nie może ich odczytać.',
+ 'settings.push_poll': 'Włączone. Ten telefon sprawdza nowe powiadomienia mniej więcej co piętnaście minut. Aby otrzymywać je od razu, możesz zainstalować aplikację dystrybutora UnifiedPush, np. ntfy (opcjonalnie).',
+ 'settings.push_global_off': 'Nic nie jest wysyłane, dopóki wszystkie powiadomienia są wyłączone.',
+ 'push.channel_chat': 'Wiadomości',
+ 'push.channel_other': 'Zaproszenia i konto',
'settings.defaults': 'Wartości domyślne',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 6fe50f6..e91ab10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -436,6 +436,15 @@ export default {
'settings.email_verified': 'Endereço de e-mail alterado com sucesso.',
'settings.notif_global_disable': 'Desativar todas as notificações',
'settings.notif_global_hint': 'Quando ativado, nenhuma notificação é criada para nenhum grupo.',
+ 'settings.push_label': 'Notificações neste telefone',
+ 'settings.push_off': 'Desativadas. Ative para saber de novas mensagens e convites com o MeshBay fechado.',
+ 'settings.push_pending': 'Aguardando a resposta do distribuidor…',
+ 'settings.push_blocked': 'O Android está bloqueando as notificações do MeshBay: permita-as nas configurações do sistema.',
+ 'settings.push_ready': 'Ativadas. Novas notificações chegam a este telefone pelo distribuidor, criptografadas para que ele não possa lê-las.',
+ 'settings.push_poll': 'Ativadas. Este telefone verifica novas notificações a cada quinze minutos, aproximadamente. Para recebê-las na hora, você pode instalar um app distribuidor UnifiedPush como o ntfy (opcional).',
+ 'settings.push_global_off': 'Nada é enviado enquanto todas as notificações estiverem desativadas.',
+ 'push.channel_chat': 'Mensagens',
+ 'push.channel_other': 'Convites e conta',
'settings.defaults': 'Padrões',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index b3886ff..c030523 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -428,6 +428,15 @@ export default {
'settings.email_verified': '邮箱地址修改成功。',
'settings.notif_global_disable': '关闭所有通知',
'settings.notif_global_hint': '启用后,所有群组都不会创建通知。',
+ 'settings.push_label': '此手机上的通知',
+ 'settings.push_off': '已关闭。开启后,即使 MeshBay 已关闭,也会收到新消息和邀请的通知。',
+ 'settings.push_pending': '正在等待分发应用的响应…',
+ 'settings.push_blocked': 'Android 正在阻止 MeshBay 的通知:请在系统设置中允许。',
+ 'settings.push_ready': '已开启。新通知通过分发应用送达此手机,并经过加密,分发应用无法读取。',
+ 'settings.push_poll': '已开启。此手机大约每十五分钟检查一次新通知。如需即时收到,可以安装 UnifiedPush 分发应用,例如 ntfy(可选)。',
+ 'settings.push_global_off': '所有通知关闭期间不会发送任何内容。',
+ 'push.channel_chat': '消息',
+ 'push.channel_other': '邀请与账户',
'settings.defaults': '默认值',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index e3b3d2d..c70b03a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -214,6 +214,22 @@ export const playback = {
},
};
+/**
+ * Notifications on this device while the application is closed — fetched, or
+ * pushed through a UnifiedPush distributor when the phone has one. Only the
+ * Android application does this; `available` is false in a browser and on a
+ * desktop, and nothing here is then called. See push.js.
+ */
+export const push = {
+ available: Boolean(bridge && bridge.push),
+ status() { return bridge.push.status(); },
+ enable() { return bridge.push.enable(); },
+ disable() { return bridge.push.disable(); },
+ remember(subscription, account, secret, since) {
+ return bridge.push.remember(subscription, account, secret, since);
+ },
+};
+
/** Pick a directory to add as a group root. Returns { path, name } or null. */
export const rootPicker = {
available: Boolean(bridge && bridge.rootPicker),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/push.js b/packages/meshbay-hub/src/meshbay_hub/static/push.js
new file mode 100644
index 0000000..a075aa4
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/push.js
@@ -0,0 +1,68 @@
+/**
+ * Notifications on this phone: the page's half.
+ *
+ * Nothing to install: the shell fetches what is new on its own every quarter
+ * of an hour, and when the phone already has a UnifiedPush distributor it is
+ * used too, so they arrive at once. The page gives the hub whatever the shell
+ * ends up with, because the page holds the session. Whether a notification is
+ * wanted — every one turned off, or a group muted — is decided on the hub,
+ * which then creates nothing (docs/MESHBAY_DESIGN.md §11.3).
+ */
+import * as platform from './platform.js';
+import { hubFetch } from './hub-client.js';
+
+// How long turning it on waits for a distributor before registering without
+// one; when the endpoint comes later, the next sync hands it over.
+const WAIT_MS = 10_000;
+const POLL_MS = 500;
+
+/**
+ * Make the hub's row match what this phone has: at every start and sign-in,
+ * and after turning it on. A distributor may hand out a new endpoint, or go
+ * away, at any time, page running or not; this is where the hub hears it.
+ */
+export async function syncPush(user) {
+ if (!platform.push.available || !user) return null;
+ const s = await platform.push.status();
+ if (!s.enabled) return s;
+ const mine = s.subscription && s.account === user.userId;
+ if (mine && s.registered === s.endpoint) return s;
+ const body = s.endpoint
+ ? { endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth }
+ : {};
+ if (mine) body.id = s.subscription;
+ const r = await hubFetch('/v1/push/subscriptions', {
+ method: 'POST', token: user.token, body,
+ });
+ return platform.push.remember(r.id, user.userId, r.poll_secret, r.now);
+}
+
+/** Turn it on: a moment for a distributor to answer, then register either way. */
+export async function enablePush(user, onProgress) {
+ let s = await platform.push.enable();
+ const until = Date.now() + WAIT_MS;
+ while (s.state === 'pending' && Date.now() < until) {
+ if (onProgress) onProgress(s);
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
+ s = await platform.push.status();
+ }
+ return syncPush(user).then((synced) => synced || s);
+}
+
+/**
+ * Turn it off: the hub forgets this phone first, then the phone stops. The hub
+ * half is the one that can fail; the phone half happens regardless, so a
+ * refused request never leaves it on here.
+ */
+export async function disablePush(user) {
+ if (!platform.push.available) return null;
+ const s = await platform.push.status();
+ if (s.subscription && user && s.account === user.userId) {
+ try {
+ await hubFetch(`/v1/push/subscriptions/${s.subscription}`, {
+ method: 'DELETE', token: user.token,
+ });
+ } catch (e) { /* already gone, or unreachable: the phone half still happens */ }
+ }
+ return platform.push.disable();
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
index 8755556..985b219 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -5,6 +5,7 @@ import { t, getLocale, setLocale, LOCALES } from './i18n.js';
import * as downloads from './downloads.js';
import * as platform from './platform.js';
import { hubFetch } from './hub-client.js';
+import { syncPush, enablePush, disablePush } from './push.js';
import { APPS } from './apps.js';
import {
PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom,
@@ -81,6 +82,35 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
}
}, [globalMute, user.token, onPrefsChange]);
+ // Notifications on this phone (Android only: `platform.push.available`).
+ const [push, setPush] = useState(null);
+ const [pushBusy, setPushBusy] = useState(false);
+ useEffect(() => {
+ if (!platform.push.available) return;
+ syncPush(user).catch(() => platform.push.status()).then(setPush).catch(() => {});
+ }, [user]);
+
+ const togglePush = useCallback(async () => {
+ if (pushBusy) return;
+ setPushBusy(true);
+ try {
+ setPush(push && push.enabled ? await disablePush(user) : await enablePush(user, setPush));
+ } catch (err) {
+ setPush(await platform.push.status().catch(() => null));
+ } finally {
+ setPushBusy(false);
+ }
+ }, [push, pushBusy, user]);
+
+ const pushHint = () => {
+ if (!push) return null;
+ if (globalMute) return t('settings.push_global_off');
+ if (!push.enabled) return t('settings.push_off');
+ if (!push.permitted) return t('settings.push_blocked');
+ if (push.state === 'pending') return t('settings.push_pending');
+ return push.endpoint ? t('settings.push_ready') : t('settings.push_poll');
+ };
+
const toggleMute = useCallback(async (gid) => {
const next = !muted[gid];
setMuted(prev => ({ ...prev, [gid]: next }));
@@ -240,6 +270,17 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
<span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
</label>
</div>
+ ${push && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.push_label')}</span>
+ <label class="toggle-switch">
+ <input type="checkbox" checked=${!!push.enabled} disabled=${pushBusy}
+ onChange=${togglePush} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ </label>
+ </div>
+ <p class="settings-hint">${pushHint()}</p>
+ `}
${!globalMute && html`
<p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p>
${groups.map(g => html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/webpush.py b/packages/meshbay-hub/src/meshbay_hub/webpush.py
new file mode 100644
index 0000000..301e191
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/webpush.py
@@ -0,0 +1,194 @@
+"""
+Web Push to a phone: RFC 8291 encryption and the one outbound request.
+
+The Android application registers with a UnifiedPush distributor (ntfy, or any
+other) and hands the hub an endpoint URL and a P-256 key; the hub POSTs each
+notification there, encrypted to that key (`docs/MESHBAY_DESIGN.md` §7.6). The
+push server relays bytes it cannot read; what it does learn is *when* this
+person is notified, which is the same metadata the hub already holds.
+
+**The endpoint is a URL a member supplied, and the hub fetches it.** That is
+the shape of an SSRF, so a send resolves the host itself, refuses unless every
+address is public, and connects to the address it checked — the hostname rides
+only as the TLS server name and the Host header, so a second resolution cannot
+point the request somewhere else. No redirect is followed.
+"""
+
+import asyncio
+import base64
+import ipaddress
+import json
+import logging
+import os
+import socket
+from dataclasses import dataclass
+from urllib.parse import urlsplit, urlunsplit
+
+import httpx
+from cryptography.hazmat.primitives import hashes, hmac, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+log = logging.getLogger(__name__)
+
+RECORD_SIZE = 4096
+SEND_TIMEOUT = 5.0
+MAX_ENDPOINT = 1024
+# RFC 8030 §5.2: a push service may keep a message this long for a phone that is
+# off. A chat line an hour old is still worth seeing; one a day old is not.
+TTL_CHAT = 3600
+TTL_OTHER = 86400
+
+
+class EndpointRefused(ValueError):
+ """The endpoint is not one the hub will send to."""
+
+
+def b64url_decode(value: str) -> bytes:
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
+
+
+def _hmac(key: bytes, data: bytes) -> bytes:
+ h = hmac.HMAC(key, hashes.SHA256())
+ h.update(data)
+ return h.finalize()
+
+
+def check_keys(p256dh: str, auth: str) -> tuple[bytes, bytes]:
+ """Decode and validate a subscription's keys; ValueError when they are not."""
+ try:
+ ua_public = b64url_decode(p256dh)
+ auth_secret = b64url_decode(auth)
+ except (ValueError, TypeError) as e:
+ raise ValueError("keys are not base64url") from e
+ if len(ua_public) != 65 or ua_public[0] != 4:
+ raise ValueError("p256dh is not an uncompressed P-256 point")
+ if len(auth_secret) != 16:
+ raise ValueError("auth is not 16 bytes")
+ # Raises ValueError for a point that is not on the curve.
+ ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
+ return ua_public, auth_secret
+
+
+def encrypt(plaintext: bytes, ua_public: bytes, auth_secret: bytes, *,
+ as_private: ec.EllipticCurvePrivateKey | None = None,
+ salt: bytes | None = None) -> bytes:
+ """One aes128gcm record (RFC 8188) keyed as RFC 8291 §3.4 says.
+
+ `as_private` and `salt` are parameters only so the RFC's own example can be
+ replayed; a send always draws both fresh.
+ """
+ if len(plaintext) > RECORD_SIZE - 16 - 1 - 86:
+ raise ValueError("push payload too large for one record")
+ as_private = as_private or ec.generate_private_key(ec.SECP256R1())
+ salt = salt or os.urandom(16)
+ as_public = as_private.public_key().public_bytes(
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
+ ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
+ ecdh_secret = as_private.exchange(ec.ECDH(), ua_key)
+
+ prk_key = _hmac(auth_secret, ecdh_secret)
+ key_info = b"WebPush: info\x00" + ua_public + as_public
+ ikm = _hmac(prk_key, key_info + b"\x01")
+ prk = _hmac(salt, ikm)
+ cek = _hmac(prk, b"Content-Encoding: aes128gcm\x00\x01")[:16]
+ nonce = _hmac(prk, b"Content-Encoding: nonce\x00\x01")[:12]
+
+ header = salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(as_public)]) + as_public
+ return header + AESGCM(cek).encrypt(nonce, plaintext + b"\x02", None)
+
+
+def check_endpoint(url: str) -> tuple[str, int]:
+ """The endpoint's shape, checked when it is registered: https, a host, no
+ credentials, not an address that is private on its face. Where its name
+ resolves is checked at every send, because that can change."""
+ if len(url) > MAX_ENDPOINT:
+ raise EndpointRefused("endpoint too long")
+ parts = urlsplit(url)
+ if parts.scheme != "https" or not parts.hostname:
+ raise EndpointRefused("endpoint must be an https URL")
+ if parts.username or parts.password:
+ raise EndpointRefused("endpoint must not carry credentials")
+ try:
+ port = parts.port or 443
+ except ValueError as e:
+ raise EndpointRefused("endpoint port is not a number") from e
+ host = parts.hostname
+ try:
+ literal = ipaddress.ip_address(host)
+ except ValueError:
+ literal = None
+ if literal is not None and not literal.is_global:
+ raise EndpointRefused("endpoint is not a public address")
+ return host, port
+
+
+async def _resolve_public(host: str, port: int) -> str:
+ infos = await asyncio.get_running_loop().getaddrinfo(
+ host, port, type=socket.SOCK_STREAM)
+ addresses = {info[4][0] for info in infos}
+ if not addresses:
+ raise EndpointRefused("endpoint does not resolve")
+ for a in addresses:
+ if not ipaddress.ip_address(a.split("%", 1)[0]).is_global:
+ raise EndpointRefused("endpoint resolves to a non-public address")
+ # IPv4 first: a hub with an AAAA answer and no IPv6 route is common.
+ return sorted(addresses, key=lambda a: (":" in a, a))[0]
+
+
+@dataclass
+class Target:
+ endpoint: str
+ p256dh: str
+ auth: str
+
+
+# Outcomes of one send, for the caller to act on.
+DELIVERED = "delivered"
+GONE = "gone" # 404/410: the registration no longer exists (RFC 8030 §7.3)
+FAILED = "failed"
+
+
+async def send(target: Target, payload: dict, *, ttl: int,
+ client: httpx.AsyncClient | None = None) -> str:
+ """Encrypt `payload` for one subscription and POST it. Never raises."""
+ try:
+ host, port = check_endpoint(target.endpoint)
+ ua_public, auth_secret = check_keys(target.p256dh, target.auth)
+ body = encrypt(json.dumps(payload, separators=(",", ":")).encode(),
+ ua_public, auth_secret)
+ address = await _resolve_public(host, port)
+ except (EndpointRefused, ValueError, OSError) as e:
+ log.info("push refused before sending: %s", e)
+ return FAILED
+
+ parts = urlsplit(target.endpoint)
+ netloc = f"[{address}]" if ":" in address else address
+ if parts.port:
+ netloc += f":{parts.port}"
+ pinned = urlunsplit((parts.scheme, netloc, parts.path or "/", parts.query, ""))
+ headers = {
+ "Host": parts.netloc,
+ "Content-Encoding": "aes128gcm",
+ "Content-Type": "application/octet-stream",
+ "TTL": str(ttl),
+ "Urgency": "normal",
+ }
+ own = client is None
+ client = client or httpx.AsyncClient(timeout=SEND_TIMEOUT, follow_redirects=False)
+ try:
+ resp = await client.post(pinned, content=body, headers=headers,
+ extensions={"sni_hostname": host})
+ except httpx.HTTPError as e:
+ # The URL path is a bearer capability for this phone: never logged.
+ log.info("push to %s failed: %s", host, type(e).__name__)
+ return FAILED
+ finally:
+ if own:
+ await client.aclose()
+ if resp.status_code in (404, 410):
+ return GONE
+ if 200 <= resp.status_code < 300:
+ return DELIVERED
+ log.info("push to %s answered %d", host, resp.status_code)
+ return FAILED