diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-09 12:08:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-09 12:08:31 +0200 |
| commit | 6832df6177ad973ad0e1b4f0a49d7a6da06c6e04 (patch) | |
| tree | d9040ce0da5d82400d1b973344615ca1c6b67b3c | |
| parent | 2860f1de75af1d44d35292ecbf79c68f02409d19 (diff) | |
| download | meshbay-6832df6177ad973ad0e1b4f0a49d7a6da06c6e04.tar.gz | |
feat: notifications on Android while closed, with nothing to install
The phone fetches what is new every fifteen minutes with a poll secret
(POST /v1/push/poll) that reads notification lines and nothing else. When a
UnifiedPush distributor is already installed, the hub also pushes at once,
encrypted to the phone (RFC 8291); losing the distributor falls back to
fetching.
The hub now honours "disable all notifications" itself: create_notification
creates nothing for that account, as it already did for a muted group, so
neither switch lets anything reach a phone. The interface used to be the only
reader of the account-wide switch.
Push endpoints are member-supplied URLs: a send refuses non-public
addresses, connects to the address it checked, and follows no redirect.
Android build untested here (no SDK on this machine).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 files changed, 1945 insertions, 11 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 9c42c62..b874632 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1987,8 +1987,9 @@ by accident (§2.4). **Stores:** accounts (username, encrypted email, status, role), the group registry and membership, IP logs (one year, legal retention), node registrations, refresh -tokens, notifications, the moderation blocklist, instance policy, and per-account -device keys for hub login. +tokens, notifications, the moderation blocklist, instance policy, per-account +device keys for hub login, and the phones that asked to be notified — a poll +secret's hash and, for a phone with a push distributor, its endpoint (§11.3). **Does not store:** file content, file names, private-group indexes, message content, private keys, group keys, keypair bundles, user identity keys, node IPs @@ -3443,6 +3444,40 @@ narrow bridge — and Android has all three: lives in the page (WebRTC → decrypt → relay), so while one runs the shell keeps the WebView reported visible and holds a media-playback foreground service; nowhere else, because a page never hidden is never throttled. +- **Notifications reach a closed application with nothing to install, and + never through a vendor push service.** Android lets nothing hold a connection + for an application that is not running, so there are three ways to wake a + phone: a vendor push service (Google sees who is notified and when; not + F-Droid), a push distributor the owner installs, or the phone asking. **The + phone asks by default**, and uses a distributor when one is already there. + Turned on, the page registers the phone (`POST /v1/push/subscriptions`) and + gets a row id and a **poll secret**; a system job (`JobScheduler`, no library) + then fetches what is new with `POST /v1/push/poll` every fifteen minutes, + which Android stretches under Doze. The secret is the only credential the + background holds and it reads notification lines and nothing else — not a + session, so nothing renewing in the background can collide with the page's + rotating refresh token, and a sign-out, which deletes the row, ends it. **When + a UnifiedPush distributor is already installed** (ntfy, or an application + carrying one), the row also gets its endpoint and P-256 key and each + notification is sent there at once as one RFC 8291 record, so the push server + relays bytes it cannot read and learns only *when* — the metadata §7.1 already + concedes to the hub; the fetch then runs every four hours as a net under it. A + distributor that refuses, disappears or answers 404/410 is not an error: the + row loses its endpoint and the phone fetches again. Both paths carry the same + payload — the hub's own row (kind, title, group, a `#/` route, its date), + never a message, which the hub does not hold — and a line pushed then fetched + is drawn once. **Nothing reaches a phone that was not created**, and + `create_notification` creates nothing for a muted group *or for an account + that turned every notification off* — the second switch used to be read only + by the interface, which hid rows the hub went on writing, and a switch only a + renderer honours is no switch once a phone is told about every row. A push + endpoint is a URL a member chose and the hub fetches it, so a send resolves + it, refuses any non-public address and connects to the address it checked + (SNI and Host carry the name); no redirect is followed. The shell draws a + pushed message only if the connector decrypted it with the phone's key, and a + notification's link is a route in the page, applied as `location.hash`, never + loaded. No VAPID yet: a distributor that requires it refuses, and the phone + fetches instead. Release builds are signed with the release key, distributed as a direct APK; the key stays outside the repository and a release build without it fails @@ -3806,6 +3841,7 @@ had already been asked. | **AV30** | **What one member's offers cost a node is bounded per account and per node, and the bound admits the heaviest ordinary account** (§7.2). Each offer makes the node allocate a peer connection. A budget of 120 per node refilled at two a second bounds a member there without touching their other nodes, and it is counted by account because a mobile carrier shares one IPv4 address among many subscribers. Pending offers are capped at 32 per account. Both refusals carry `Retry-After` and the client retries them, because a refused offer otherwise reads as a node that is down. An offer carries at most 64 ICE candidates (32 KiB), and its IP-log row — kept a year — is written only once it goes to a node, so naming nodes that do not exist costs the hub nothing. A node's `update_groups`, a database read each, is budgeted like `chat_notify` (ten a minute) and claims at most 1000 groups | | **AV32** | **A node hosts a group because its owner said so, not because its account belongs to it** (§7.2). Every member holds the key, so a member's node passes the handshake like the real host and could be the one a client keeps. A node may claim the groups its account owns and those whose owner approved it; any other claim is a pending request the owner sees | | **AV33** | **Nobody is made a member without saying yes** (§7.3). A membership makes the account's client list the group, name it in its tokens and dial its nodes, so an owner's addition is an invitation until the invitee accepts it. The MNP token names only the group it is minted for | +| **AV34** | **What a member's chat costs other members' phones is bounded twice, and what a phone's fetching costs the hub once** (§11.3). A pushed notification is one outbound request per subscription of the recipient, so the fan-out of one chat line is members × phones. It is bounded where it starts (`chat_notify`, ten a minute per node), a conversation reaches each phone at most once per 30 s (the phone shows one line per group, so the pushes in between would only replace it), and an account holds ten subscriptions at most. A subscription the push server reports gone (404/410) loses its endpoint rather than being retried for ever. A phone fetching instead costs one indexed read per poll, refused below a minute per row, and returns at most twenty lines | | **AV31** | **What waits for a signature is bounded** (§5.4). Any authenticated member can ask for an admin challenge, since the signature is checked afterwards, and a pending challenge kept its whole request until answered — measured, 200 requests of 1 MiB held 400 MiB for the life of one connection. At most eight pending per connection, 64 KiB each, expired ones dropped | ### 13.6 Chat design findings @@ -4000,8 +4036,8 @@ Music and Photos, cross-group search with source merging, per-account playlists, casting to a Chromecast with subtitles rebased onto the relay's clock, the operator CLI and loopback control API, the desktop client through its identity and download stages, account recovery, the Windows port through packaging, and -the Android client — the shell, native keys, downloads and uploads, and casting -(§11.3). +the Android client — the shell, native keys, downloads and uploads, casting, +and notifications while it is closed, fetched or pushed (§11.3). The packages install: a machine has been taken from the built artefacts to a running hub and node on **Ubuntu 26.04 (`.deb`), Fedora 44 (`.rpm`) and diff --git a/docs/MESHBAY_HTTP_API.md b/docs/MESHBAY_HTTP_API.md index 74b9d77..1bcb55d 100644 --- a/docs/MESHBAY_HTTP_API.md +++ b/docs/MESHBAY_HTTP_API.md @@ -180,6 +180,14 @@ hub also serves the web application at `/` and `/app/`, which are not listed. | DELETE | `/v1/notifications` | user or node | Throw them all away. | | POST | `/v1/notifications/read-all` | user or node | Dismiss every one — the same thing as `DELETE ""`, under the name an older client knows it by. | +### Push + +| Method | Path | Auth | What | +|---|---|---|---| +| POST | `/v1/push/subscriptions` | user | 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. | +| POST | `/v1/push/poll` | none | What is new for this phone since `since`: the same payloads a push carries, oldest first, at most twenty. | +| DELETE | `/v1/push/subscriptions/{subscription_id}` | user | Stop telling one phone anything: turned off there, or signed out of. | + ## Node control API `http://127.0.0.1:<ui_port>`, port 18000 unless `ui_port` in `node.toml` says diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 275a70d..eb659e1 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -447,6 +447,16 @@ designed and not built. **Settings → Defaults** — which tab a group opens on, how many items per page. **Settings → Appearance** — theme and language (ten languages ship). **Group menu → Mute** — stop notifications for one group. +**Settings → Groups → Disable all notifications** — none are kept for you at +all, and none reach a phone. +**Settings → Groups → Notifications on this phone** (Android application) — +be told about new messages and invitations while MeshBay is closed. Nothing to +install: the phone checks about every fifteen minutes (Android may wait longer +when the phone is asleep). If you want them to arrive at once, install a +UnifiedPush *distributor* app such as **ntfy** (Play Store or F-Droid) — optional; +MeshBay then uses it by itself, encrypted so the distributor cannot read what it +carries. Muting a group, or disabling all notifications, stops what reaches the +phone too. A busy conversation is one line per group. --- @@ -1008,7 +1018,9 @@ Better to know now than to go looking for it: - **The Android application is not released yet.** It works — groups, chat, films, downloads, casting — and is signed with the release key, but is installed by hand from an APK: there is no store page and no update channel. The - back button and the lock screen do not drive it yet. Music keeps playing + back button and the lock screen do not drive it yet. Notifications arrive within about + fifteen minutes, or at once with a UnifiedPush distributor app such as ntfy + — except one that demands a server key (VAPID), not supported yet. Music keeps playing with the screen off, from one track to the next, with a notification shown while it plays. A phone browser works too. - **A node cannot be hosted on Android**, and is not planned to be. diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md index 5cb474e..69977ec 100644 --- a/packages/meshbay-android/README.md +++ b/packages/meshbay-android/README.md @@ -53,6 +53,15 @@ does not install over a debug build, or the reverse: the keys differ. The security contract is also pinned from the Python suite by reading this source: `packages/meshbay-hub/tests/test_android_shell.py`. +Notifications while closed, with nothing to install: `notify/PollJob` (a +system job, no library) fetches what is new from the hub every fifteen minutes +with the phone's poll secret — never a session. When a UnifiedPush distributor +is already on the phone (ntfy, …) it is used too: the hub pushes at once, +encrypted to the phone (RFC 8291), `notify/PushReceiver` draws what the +connector could decrypt, and the fetch slows to a four-hour net. The page turns +it on in Settings and registers the phone with the hub; muting and "disable +all" are decided on the hub, which then creates nothing (§11.3). + Not built yet: phone-specific behaviour (back button, network handover, keeping a download alive with the screen off), updates through a store. diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts index 8afbc1a..7f7ba3c 100644 --- a/packages/meshbay-android/app/build.gradle.kts +++ b/packages/meshbay-android/app/build.gradle.kts @@ -57,6 +57,11 @@ dependencies { // run time; where they are absent the page is offered no cast at all. implementation("com.google.android.gms:play-services-cast-framework:22.3.1") implementation("androidx.mediarouter:mediarouter:1.8.1") + // Notifications are fetched with nothing to install; this is only for a + // phone that already has a UnifiedPush distributor (ntfy, …), which then + // makes them instant, encrypted to the phone (RFC 8291) — no vendor push + // service. Apache-2.0, as is Tink, which it brings for the decryption. + implementation("org.unifiedpush.android:connector:3.3.5") testImplementation("junit:junit:4.13.2") // Android's org.json is a stub on the JVM; the unit tests need the real one. testImplementation("org.json:json:20260814") diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 2eae3ea..0c234aa 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -8,6 +8,11 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + <!-- Notifications from the hub, fetched or pushed; asked for when the + person turns them on, never at start. --> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <!-- The periodic fetch survives a reboot (JobInfo.setPersisted). --> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- No backup of any kind: the keys are wrapped by a Keystore key that a restore cannot bring with it, so a backed-up store is one that silently @@ -36,6 +41,19 @@ android:name=".cast.CastService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> + <!-- Not exported: the connector's own receiver takes the distributor's + broadcasts and hands them here inside the application. --> + <service + android:name=".notify.PushReceiver" + android:exported="false"> + <intent-filter> + <action android:name="org.unifiedpush.android.connector.PUSH_EVENT" /> + </intent-filter> + </service> + <service + android:name=".notify.PollJob" + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> <meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="org.meshbay.client.cast.CastOptionsProvider" /> diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index b71b5e8..82e556d 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -186,6 +186,17 @@ keepAlive: (on) => call('playback:keep-alive', on === true), }, + // Notifications while closed: fetched, or pushed through a UnifiedPush + // distributor when the phone has one. Phone-only: the + // desktop preload has no counterpart, so `platform.push` is absent there. + push: { + status: () => call('push:status'), + enable: () => call('push:enable'), + disable: () => call('push:disable'), + remember: (subscription, account, secret, since) => + call('push:remember', subscription, account, secret, since), + }, + // Where downloads go, chosen once. A display name comes back, never a URI. folder: { choose: () => call('folder:choose'), diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 0fa63d6..dad8462 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -32,6 +32,9 @@ import org.meshbay.client.cast.CastChannels import org.meshbay.client.cast.CastService import org.meshbay.client.hub.HubClient import org.meshbay.client.keys.SecretStore +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushChannels +import org.meshbay.client.notify.PushState import org.meshbay.client.save.SaveSinks import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView @@ -62,6 +65,7 @@ class MainActivity : Activity() { private var playing = false private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null + private var pendingLink: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -89,13 +93,28 @@ class MainActivity : Activity() { tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, - cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }) + cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }, + push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)), + channelNames = { mapOf( + Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), + Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) })) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() + // Opened from a notification: to what it was about, once the page is up. + pendingLink = Notifier.linkOf(intent) web.loadUrl(UiAssets.START) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + Notifier.linkOf(intent)?.let { if (::web.isInitialized) goTo(it) } + } + + /** A route inside the page (`#/…`), checked by Notifier — never a URL to load. */ + private fun goTo(link: String) = web.evaluateJavascript("location.hash = ${JSONObject.quote(link)};", null) + private fun configure(web: WebView) { WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) web.settings.apply { @@ -116,6 +135,10 @@ class MainActivity : Activity() { .addPathHandler(UiAssets.PREFIX, UiAssets(this)) .build() web.webViewClient = object : WebViewClientCompat() { + override fun onPageFinished(view: WebView, url: String) { + pendingLink?.let { pendingLink = null; goTo(it) } + } + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { val url = request.url if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index f7094a0..5f202ba 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -3,6 +3,7 @@ package org.meshbay.client.bridge import org.json.JSONArray import org.meshbay.client.cast.CastChannels import org.meshbay.client.hub.HubClient +import org.meshbay.client.notify.PushChannels import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks import java.net.Inet4Address @@ -26,6 +27,7 @@ class Channels( private val saves: SaveSinks? = null, private val cast: CastChannels? = null, private val onPlayback: (Boolean) -> Unit = {}, + private val push: PushChannels? = null, ) { @Volatile var locale = "en" private set @@ -53,6 +55,7 @@ class Channels( else -> when { keys != null && keys.handles(channel) -> keys.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) + push != null && push.handles(channel) -> push.call(channel, args) else -> throw Refused("Refused: no such channel") } } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt new file mode 100644 index 0000000..7e6fce6 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.notify + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import org.json.JSONObject +import org.meshbay.client.MainActivity +import org.meshbay.client.R + +/** + * A notification from the hub, pushed or fetched, drawn by the system. + * + * The text is the hub's own line ("… posted in …") — the hub never holds a + * message, so neither does a push. A conversation is one entry per group, + * replaced as it goes on, as it is one row on the hub. + */ +object Notifier { + const val CHANNEL_CHAT = "chat" + const val CHANNEL_OTHER = "account" + const val EXTRA_LINK = "org.meshbay.client.LINK" + private val LINK = Regex("^#/[A-Za-z0-9/_-]{0,200}$") + + data class Shown(val channel: String, val tag: String, val title: String, val link: String?, + val id: Long, val createdAt: String) + + /** + * What to draw for a payload, or null for one that is not ours to draw. + * It was decrypted with this phone's key or fetched from the signed-in hub + * with this phone's secret; it is still checked like any input. + */ + fun parse(content: ByteArray): Shown? { + val o = try { JSONObject(String(content, Charsets.UTF_8)) } catch (e: Exception) { return null } + if (o.optInt("v", 0) != 1) return null + val kind = o.optString("kind", "").take(32) + val title = o.optString("title", "").take(256).ifBlank { return null } + val group = if (o.isNull("group_id")) "" else o.optString("group_id", "").take(64) + val link = (if (o.isNull("link")) null else o.optString("link", null))?.takeIf { LINK.matches(it) } + val chat = kind == "chat_message" && group.isNotEmpty() + val id = o.optLong("id", 0) + val createdAt = o.optString("created_at", "").take(40) + val tag = if (chat) "chat:$group" else "n:$id" + return Shown(if (chat) CHANNEL_CHAT else CHANNEL_OTHER, tag, title, link, id, createdAt) + } + + /** Draw it unless it was already drawn — pushed, then fetched, is one line. */ + fun deliver(context: Context, state: PushState, shown: Shown) { + if (state.firstSight(shown.id, shown.createdAt)) show(context, shown) + } + + /** + * The two channels, named in the person's language. Called with names when + * the page turns push on; from the receiver with none, only to make sure a + * channel exists, so a localized name is never overwritten by the fallback. + */ + fun ensureChannels(context: Context, names: Map<String, String>? = null) { + val nm = context.getSystemService(NotificationManager::class.java) + for ((id, fallback, importance) in listOf( + Triple(CHANNEL_CHAT, "Messages", NotificationManager.IMPORTANCE_DEFAULT), + Triple(CHANNEL_OTHER, "Invitations and account", NotificationManager.IMPORTANCE_DEFAULT), + )) { + if (names == null && nm.getNotificationChannel(id) != null) continue + nm.createNotificationChannel(NotificationChannel(id, names?.get(id) ?: fallback, importance)) + } + } + + fun show(context: Context, shown: Shown) { + ensureChannels(context) + val open = Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + shown.link?.let { open.putExtra(EXTRA_LINK, it) } + val pending = PendingIntent.getActivity(context, shown.tag.hashCode(), open, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val n = Notification.Builder(context, shown.channel) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle("MeshBay") + .setContentText(shown.title) + .setStyle(Notification.BigTextStyle().bigText(shown.title)) + .setContentIntent(pending) + .setAutoCancel(true) + .setCategory(if (shown.channel == CHANNEL_CHAT) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_SOCIAL) + .build() + context.getSystemService(NotificationManager::class.java).notify(shown.tag, 1, n) + } + + /** A link from a notification the shell itself drew, checked again on the way in. */ + fun linkOf(intent: Intent?): String? = intent?.getStringExtra(EXTRA_LINK)?.takeIf { LINK.matches(it) } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt new file mode 100644 index 0000000..0998d00 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt @@ -0,0 +1,65 @@ +package org.meshbay.client.notify + +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.Context +import android.util.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.meshbay.client.bridge.Bridge +import org.meshbay.client.hub.HubClient +import java.util.concurrent.TimeUnit + +/** + * One fetch of what is new (`POST /v1/push/poll`), page running or not. + * + * Authenticated by the row's poll secret, never a session: what this keeps for + * running in the background reads notification lines and nothing else. It goes + * to the hub the application is signed in to, and only there. + */ +class PollJob : JobService() { + @Volatile private var worker: Thread? = null + + override fun onStartJob(params: JobParameters): Boolean { + worker = Thread { + try { fetch() } catch (e: Exception) { Log.w(Bridge.TAG, "poll failed: ${e.javaClass.simpleName}") } + jobFinished(params, false) + }.apply { start() } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + worker?.interrupt() + return false + } + + private fun fetch() { + val state = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)) + val target = state.pollTarget() ?: return + val base = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)).base + val url = base.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/push/poll")?.build() ?: return + val body = JSONObject().put("id", target.id).put("secret", target.secret).put("since", target.since) + .toString().toRequestBody("application/json".toMediaType()) + val http = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).followRedirects(false).build() + http.newCall(Request.Builder().url(url).post(body).build()).execute().use { r -> + when { + // The row is gone — signed out elsewhere, or deleted: the page + // registers again when it next runs, if push is still on. + r.code == 404 -> state.forgetSubscription() + r.isSuccessful -> { + val list = JSONObject(r.body.string()).optJSONArray("notifications") ?: return + for (i in 0 until list.length()) { + val raw = list.optJSONObject(i)?.toString()?.toByteArray() ?: continue + val shown = Notifier.parse(raw) ?: continue + Notifier.deliver(this, state, shown) + state.advance(shown.createdAt) + } + } + } + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt new file mode 100644 index 0000000..8f58512 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt @@ -0,0 +1,29 @@ +package org.meshbay.client.notify + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context + +/** + * The periodic fetch, through the system's own job scheduler: no library and + * nothing the platform does not already run. Android decides the exact moment + * (fifteen minutes is the shortest period it allows, and Doze stretches it); + * a phone that also gets pushes keeps a slow fetch as a net under them. + */ +object Poller { + private const val JOB_ID = 4208 + private const val FETCHING_MS = 15L * 60 * 1000 + private const val UNDER_PUSH_MS = 4L * 3600 * 1000 + + fun schedule(context: Context, pushed: Boolean) { + val job = JobInfo.Builder(JOB_ID, ComponentName(context, PollJob::class.java)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPeriodic(if (pushed) UNDER_PUSH_MS else FETCHING_MS) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java).schedule(job) + } + + fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID) +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt new file mode 100644 index 0000000..e8619a5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt @@ -0,0 +1,76 @@ +package org.meshbay.client.notify + +import android.app.Activity +import android.app.NotificationManager +import android.os.Build +import org.json.JSONArray +import org.json.JSONObject +import org.unifiedpush.android.connector.UnifiedPush + +/** + * Notifications on this phone (§11.3), with nothing to install. + * + * Turned on, the phone fetches what is new every quarter of an hour (`PollJob`). + * If a UnifiedPush distributor is already on the phone — ntfy, or an application + * that carries one — it is used as well, and notifications arrive at once; if it + * refuses or goes away, the phone simply goes back to fetching. The page gives + * the hub whatever this side ends up with. Phone-only: the desktop has no + * counterpart, and its preload has no such channels. + * + * Whether a notification is wanted at all — every one turned off, or one group + * muted — is decided on the hub, which then creates nothing, so nothing is + * pushed or fetched. Nothing here second-guesses it. + */ +class PushChannels( + private val activity: Activity, + private val state: PushState, + private val channelNames: () -> Map<String, String>, +) { + fun handles(channel: String) = channel.startsWith("push:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "push:status" -> status() + "push:enable" -> enable() + "push:disable" -> { disable(); status() } + "push:remember" -> { + state.remember(args.optString(0, ""), args.optString(1, ""), args.optString(2, ""), args.optString(3, "")) + Poller.schedule(activity, pushed = state.endpoint != null) + status() + } + else -> throw org.meshbay.client.bridge.Refused("Refused: no such channel") + } + + private fun distributors() = UnifiedPush.getDistributors(activity).any { it != activity.packageName } + + private fun status(): JSONObject = state.status() + .put("distributors", distributors()) + .put("permitted", activity.getSystemService(NotificationManager::class.java).areNotificationsEnabled()) + + private fun enable(): JSONObject { + state.requested() + Notifier.ensureChannels(activity, channelNames()) + if (!distributors()) state.fellBack("NO_DISTRIBUTOR") + activity.runOnUiThread { + if (Build.VERSION.SDK_INT >= 33) { + activity.requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST) + } + // Only when one is installed: the system's chooser for a person who + // has none would be a screen about something they never asked for. + if (distributors()) UnifiedPush.tryUseDefaultDistributor(activity) { found -> + if (found) UnifiedPush.register(activity, messageForDistributor = "MeshBay") + else state.fellBack("NO_DISTRIBUTOR") + } + } + return status() + } + + private fun disable() { + Poller.cancel(activity) + try { UnifiedPush.removeDistributor(activity) } catch (e: Exception) { /* none was saved */ } + state.cleared() + } + + companion object { + private const val PERMISSION_REQUEST = 4207 + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt new file mode 100644 index 0000000..64ea7a7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt @@ -0,0 +1,41 @@ +package org.meshbay.client.notify + +import android.content.Context +import android.util.Log +import org.meshbay.client.bridge.Bridge +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +/** + * What a distributor tells this application, page running or not — when the + * phone has one. Losing it is not an error: the phone goes back to fetching + * (`PollJob`), and the page tells the hub at its next start. + * + * A message is drawn only if the connector decrypted it with this phone's + * key: anything else did not come from a hub holding the subscription. + */ +class PushReceiver : PushService() { + private fun state() = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)) + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + state().endpoint(endpoint.url, endpoint.pubKeySet?.pubKey, endpoint.pubKeySet?.auth) + } + + override fun onMessage(message: PushMessage, instance: String) { + val state = state() + if (!message.decrypted || !state.enabled) { Log.w(Bridge.TAG, "push message dropped"); return } + Notifier.parse(message.content)?.let { Notifier.deliver(this, state, it) } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) = fallBack(reason.name) + + override fun onUnregistered(instance: String) = fallBack("UNREGISTERED") + + private fun fallBack(reason: String) { + val state = state() + state.fellBack(reason) + if (state.enabled) Poller.schedule(this, pushed = false) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt new file mode 100644 index 0000000..3905d58 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt @@ -0,0 +1,126 @@ +package org.meshbay.client.notify + +import android.content.SharedPreferences +import org.json.JSONObject + +/** + * Where this phone stands with the hub and, when it has one, with a push + * distributor — kept across restarts, because both answer whenever they like, + * often with the page not running. + * + * Turned on, it is always `ready` in the end: with an endpoint when a + * distributor gave one (pushed, instantly), without one otherwise (fetched, + * every quarter of an hour). Nothing to install is the default; a distributor + * is a bonus, and losing it falls back rather than failing. + * + * `registered` is the endpoint the hub was last given (null for none). When it + * differs from `endpoint` the page registers again; that is the whole of what + * keeps the hub's row current. + */ +class PushState(private val prefs: SharedPreferences) { + + fun status(): JSONObject = JSONObject() + .put("enabled", enabled) + .put("state", prefs.getString(STATE, OFF)) + .put("reason", prefs.getString(REASON, null) ?: JSONObject.NULL) + .put("endpoint", endpoint ?: JSONObject.NULL) + .put("p256dh", prefs.getString(P256DH, null) ?: JSONObject.NULL) + .put("auth", prefs.getString(AUTH, null) ?: JSONObject.NULL) + .put("subscription", prefs.getString(SUBSCRIPTION, null) ?: JSONObject.NULL) + .put("account", prefs.getString(ACCOUNT, null) ?: JSONObject.NULL) + .put("registered", prefs.getString(REGISTERED, null) ?: JSONObject.NULL) + + val enabled get() = prefs.getBoolean(ENABLED, false) + val endpoint: String? get() = prefs.getString(ENDPOINT, null) + + fun requested() = prefs.edit().putBoolean(ENABLED, true).putString(STATE, PENDING).remove(REASON).apply() + + fun endpoint(url: String, p256dh: String?, auth: String?) { + if (!enabled) return + // A distributor speaking only the old protocol gives no keys, and + // nothing could be encrypted to it: fetch instead. + if (p256dh == null || auth == null) { fellBack("NO_KEYS"); return } + prefs.edit().putString(STATE, READY).remove(REASON) + .putString(ENDPOINT, url).putString(P256DH, p256dh).putString(AUTH, auth).apply() + } + + /** No distributor, or it refused or dropped us: fetch, and say why. */ + fun fellBack(reason: String) { + if (!enabled) return + prefs.edit().putString(STATE, READY).putString(REASON, reason) + .remove(ENDPOINT).remove(P256DH).remove(AUTH).apply() + } + + /** What the hub answered a registration: its row, the account, the poll secret, its clock. */ + fun remember(subscription: String, account: String, secret: String, since: String) { + require(SUBSCRIPTION_ID.matches(subscription) && ACCOUNT_ID.matches(account) && + SECRET.matches(secret) && SINCE.matches(since)) { "bad subscription" } + prefs.edit().putString(SUBSCRIPTION, subscription).putString(ACCOUNT, account) + .putString(SECRET_KEY, secret).putString(SINCE_KEY, since) + .putString(REGISTERED, endpoint).apply() + } + + /** The hub no longer knows this row: the page registers again when it next runs. */ + fun forgetSubscription() = prefs.edit().remove(SUBSCRIPTION).remove(SECRET_KEY).remove(REGISTERED).apply() + + data class PollTarget(val id: String, val secret: String, val since: String) + + fun pollTarget(): PollTarget? { + if (!enabled) return null + return PollTarget(prefs.getString(SUBSCRIPTION, null) ?: return null, + prefs.getString(SECRET_KEY, null) ?: return null, + prefs.getString(SINCE_KEY, null) ?: return null) + } + + /** Fetch from here next time. ISO-8601 from the hub's own clock, so they compare as text. */ + fun advance(cursor: String) { + if (SINCE.matches(cursor) && cursor > (prefs.getString(SINCE_KEY, "") ?: "")) { + prefs.edit().putString(SINCE_KEY, cursor).apply() + } + } + + /** + * True the first time this line is seen at this date — pushed and then + * fetched, it is drawn once. A conversation keeps its id and moves its + * date, so a newer date is news again. + */ + @Synchronized + fun firstSight(id: Long, createdAt: String): Boolean { + val seen = try { JSONObject(prefs.getString(SEEN, "{}") ?: "{}") } catch (e: Exception) { JSONObject() } + val key = id.toString() + if (seen.optString(key, "") >= createdAt) return false + seen.put(key, createdAt) + if (seen.length() > SEEN_MAX) { + seen.keys().asSequence().toList().sortedBy { seen.optString(it) } + .take(seen.length() - SEEN_MAX).forEach { seen.remove(it) } + } + prefs.edit().putString(SEEN, seen.toString()).apply() + return true + } + + fun cleared() = prefs.edit().clear().apply() + + companion object { + const val OFF = "off" + const val PENDING = "pending" + const val READY = "ready" + private const val ENABLED = "enabled" + private const val STATE = "state" + private const val REASON = "reason" + private const val ENDPOINT = "endpoint" + private const val P256DH = "p256dh" + private const val AUTH = "auth" + private const val SUBSCRIPTION = "subscription" + private const val ACCOUNT = "account" + private const val REGISTERED = "registered" + private const val SECRET_KEY = "pollSecret" + private const val SINCE_KEY = "since" + private const val SEEN = "seen" + private const val SEEN_MAX = 100 + private val SUBSCRIPTION_ID = Regex("^[0-9a-f-]{36}$") + private val ACCOUNT_ID = Regex("^[0-9A-Za-z-]{1,64}$") + private val SECRET = Regex("^[A-Za-z0-9_-]{20,64}$") + private val SINCE = Regex("^\\d{4}-\\d\\d-\\d\\dT[0-9:.]+(\\+00:00|Z)$") + const val PREFS = "push" + } +} diff --git a/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml new file mode 100644 index 0000000..ec26359 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The status-bar icon: the system keeps only its alpha, so a plain M. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M3,20V4h3l6,8 6,-8h3v16h-3V9l-6,8 -6,-8v11z" /> +</vector> diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt new file mode 100644 index 0000000..c575906 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt @@ -0,0 +1,108 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushState + +class PushTest { + private fun payload(vararg pairs: Pair<String, Any?>) = + JSONObject().apply { put("v", 1); pairs.forEach { (k, v) -> put(k, v ?: JSONObject.NULL) } } + .toString().toByteArray() + + @Test fun `a conversation is one entry per group, anything else one per notification`() { + val chat = Notifier.parse(payload("id" to 7, "kind" to "chat_message", "title" to "a posted in b", + "group_id" to "g-1", "link" to "#/group/g-1"))!! + assertEquals(Notifier.CHANNEL_CHAT, chat.channel) + assertEquals("chat:g-1", chat.tag) + assertEquals("#/group/g-1", chat.link) + val invite = Notifier.parse(payload("id" to 8, "kind" to "group_invite", "title" to "x invited you", + "group_id" to null, "link" to "#/"))!! + assertEquals(Notifier.CHANNEL_OTHER, invite.channel) + assertEquals("n:8", invite.tag) + } + + @Test fun `a link that is not a route inside the page is dropped, not followed`() { + for (bad in listOf("https://elsewhere.example/", "javascript:alert(1)", "#/x\";alert(1)//", "//host/#/")) { + val shown = Notifier.parse(payload("id" to 1, "kind" to "k", "title" to "t", "link" to bad))!! + assertNull(bad, shown.link) + } + } + + @Test fun `what is not ours to draw is not drawn`() { + assertNull(Notifier.parse("not json".toByteArray())) + assertNull(Notifier.parse(JSONObject().put("v", 2).put("title", "t").toString().toByteArray())) + assertNull(Notifier.parse(payload("id" to 1, "kind" to "k", "title" to " "))) + } + + private val sub = "0f8fad5b-d9cb-469f-a165-70867728950e" + private val account = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val secret = "Zm9vYmFyYmF6cXV4X3NlY3JldF92YWx1ZQ" + private val since = "2026-10-09T10:00:00.123456+00:00" + + @Test fun `the hub is registered again when the distributor hands out a new endpoint`() { + val state = PushState(FakePrefs()) + state.endpoint("https://push.example.net/1", "k", "a") // not asked for: ignored + assertEquals(PushState.OFF, state.status().getString("state")) + state.requested() + state.endpoint("https://push.example.net/1", "k", "a") + state.remember(sub, account, secret, since) + val s = state.status() + assertEquals(s.getString("endpoint"), s.getString("registered")) + state.endpoint("https://push.example.net/2", "k", "a") + val moved = state.status() + assertEquals("https://push.example.net/1", moved.getString("registered")) + assertEquals("https://push.example.net/2", moved.getString("endpoint")) + } + + @Test fun `no distributor, or one that gives no keys, means fetching and not failing`() { + val state = PushState(FakePrefs()) + state.requested() + state.endpoint("https://push.example.net/1", null, null) + val s = state.status() + assertEquals(PushState.READY, s.getString("state")) + assertEquals("NO_KEYS", s.getString("reason")) + assertTrue(s.isNull("endpoint")) + state.remember(sub, account, secret, since) + assertEquals(PushState.PollTarget(sub, secret, since), state.pollTarget()) + } + + @Test fun `the fetch cursor only moves forward`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.advance("2026-10-09T09:00:00+00:00") + assertEquals(since, state.pollTarget()!!.since) + state.advance("2026-10-09T11:00:00+00:00") + assertEquals("2026-10-09T11:00:00+00:00", state.pollTarget()!!.since) + } + + @Test fun `a line pushed then fetched is drawn once, and a conversation moving on is news`() { + val state = PushState(FakePrefs()) + assertTrue(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertTrue(state.firstSight(7, "2026-10-09T10:05:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:01:00+00:00")) + } + + @Test fun `a row the hub forgot stops the fetch until the page registers again`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.forgetSubscription() + assertNull(state.pollTarget()) + } + + @Test fun `only ids are remembered`() { + val state = PushState(FakePrefs()) + assertThrows(IllegalArgumentException::class.java) { state.remember("../x", account, secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, "", secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, "short", since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, secret, "yesterday") } + } +} 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 diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index 71832f2..129732c 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -135,11 +135,15 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js)) native = set() for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt", - SRC / "cast" / "CastChannels.kt"): + SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt"): native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M)) shim = _shim_channels() assert shim, "no channel found in the shim" - assert shim <= preload, f"channels the desktop does not have: {shim - preload}" + # A phone has a push distributor to talk to and a desktop does not; that + # family is the one the desktop lacks rather than the one it shares. + phone_only = {c for c in shim if c.startswith("push:")} + assert phone_only, "the push channels are gone from the shim" + assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}" assert shim == native, f"shim and native disagree: {shim ^ native}" @@ -196,6 +200,28 @@ def test_nothing_is_granted_and_video_may_go_fullscreen(): assert "override fun onHideCustomView" in activity +def test_a_notification_is_drawn_only_when_it_opened_and_leads_inside_the_page(): + """The distributor is another application: its receiver must not be ours + to call, a message nobody encrypted to this phone is not drawn, and the + link a notification carries is a route in the page, never a URL.""" + manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") + service = manifest.split('android:name=".notify.PushReceiver"', 1)[1].split("</service>", 1)[0] + assert 'android:exported="false"' in service + job = manifest.split('android:name=".notify.PollJob"', 1)[1].split("/>", 1)[0] + assert 'android:exported="false"' in job and "BIND_JOB_SERVICE" in job + # The background fetch carries the poll secret, never a session token. + poll = _read(SRC / "notify" / "PollJob.kt") + assert "/v1/push/poll" in poll and "Authorization" not in poll + receiver = _read(SRC / "notify" / "PushReceiver.kt") + assert "!message.decrypted" in receiver + notifier = _read(SRC / "notify" / "Notifier.kt") + assert 'Regex("^#/[A-Za-z0-9/_-]{0,200}$")' in notifier + assert "FLAG_IMMUTABLE" in notifier + activity = _read(SRC / "MainActivity.kt") + assert activity.count("Notifier.linkOf(intent)") == 2 + assert 'location.hash = ${JSONObject.quote(link)}' in activity + + def test_no_backup_carries_the_keys_away(): manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") assert 'android:allowBackup="false"' in manifest diff --git a/packages/meshbay-hub/tests/test_push.py b/packages/meshbay-hub/tests/test_push.py new file mode 100644 index 0000000..c3bca4f --- /dev/null +++ b/packages/meshbay-hub/tests/test_push.py @@ -0,0 +1,455 @@ +""" +Push to a phone: the encryption, the endpoint a member supplies, and — the +point of the whole feature — that the two switches a person sees are honoured. + +Every flow test is two accounts, the one causing a notification and the one +receiving it, because a one-member test proves a one-member property. +""" + +import base64 +import hashlib +import json + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes, hmac, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from membership import add_member +from meshbay_hub import webpush +from meshbay_hub.api import push +from meshbay_hub.db.models import Notification, PushSubscription, User +from sqlalchemy import select + + +def _b64(s: str) -> bytes: + s = "".join(s.split()) + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def _b64e(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return r.json()["access_token"] + + +def _phone(): + """A user agent's keys, as a distributor's connector would generate them.""" + sk = ec.generate_private_key(ec.SECP256R1()) + pk = sk.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + auth = b"0123456789abcdef" + return sk, _b64e(pk), _b64e(auth) + + +def _hm(key, data): + h = hmac.HMAC(key, hashes.SHA256()) + h.update(data) + return h.finalize() + + +def _decrypt(body: bytes, ua_private, auth_secret: bytes) -> bytes: + """RFC 8291 from the receiving side, written from the RFC and not from webpush.py.""" + salt, idlen = body[:16], body[20] + as_public = body[21:21 + idlen] + ua_public = ua_private.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + ecdh = ua_private.exchange( + ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public)) + ikm = _hm(_hm(auth_secret, ecdh), b"WebPush: info\x00" + ua_public + as_public + b"\x01") + prk = _hm(salt, ikm) + cek = _hm(prk, b"Content-Encoding: aes128gcm\x00\x01")[:16] + nonce = _hm(prk, b"Content-Encoding: nonce\x00\x01")[:12] + plain = AESGCM(cek).decrypt(nonce, body[21 + idlen:], None) + assert plain.endswith(b"\x02") + return plain[:-1] + + +@pytest.fixture +def sent(monkeypatch): + """Every push the hub would have sent, instead of sending it.""" + calls = [] + + async def fake_send(target, payload, *, ttl, client=None): + calls.append((target, payload, ttl)) + return webpush.DELIVERED + + monkeypatch.setattr(push, "_send", fake_send) + push._last_chat.clear() + return calls + + +async def _subscribe(client, token, endpoint="https://push.example.net/up/abc"): + _, p256dh, auth = _phone() + r = await client.post("/v1/push/subscriptions", + json={"endpoint": endpoint, "p256dh": p256dh, "auth": auth}, + headers={"Authorization": f"Bearer {token}"}) + return r + + +async def _two_in_a_group(client, db_session, name): + member = await _user(client, f"{name}_member") + owner = await _user(client, f"{name}_owner") + g = await client.post("/v1/groups", json={"name": name}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + await add_member(client, gid, f"{name}_member", {"Authorization": f"Bearer {owner}"}) + uid = (await db_session.execute( + select(User.id).where(User.username == f"{name}_member"))).scalar_one() + return member, uid, gid + + +# ── Encryption ─────────────────────────────────────────────────────────────── + +def test_encryption_reproduces_the_rfc_8291_example(): + as_private = ec.derive_private_key( + int.from_bytes(_b64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"), "big"), + ec.SECP256R1()) + out = webpush.encrypt( + _b64("V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24"), + _b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"), + _b64("BTBZMqHH6r4Tts7J_aSIgg"), + as_private=as_private, salt=_b64("DGv6ra1nlYgDCS1FRnbzlw")) + header = _b64("DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" + "mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8") + ciphertext = _b64("8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEs" + "bI_0LpXMuGvnzQ") + assert out == header + ciphertext + + +def test_a_fresh_encryption_opens_on_the_phone(): + sk, p256dh, auth = _phone() + body = webpush.encrypt(b'{"kind":"x"}', _b64(p256dh), _b64(auth)) + assert _decrypt(body, sk, _b64(auth)) == b'{"kind":"x"}' + + +# ── The endpoint is a URL a member chose, and the hub fetches it ───────────── + +@pytest.mark.parametrize("endpoint", [ + "http://push.example.net/up/abc", + "https://127.0.0.1/up", + "https://10.1.2.3/up", + "https://[::1]/up", + "https://169.254.169.254/latest", + "https://user:pw@push.example.net/up", + "ftp://push.example.net/up", +]) +@pytest.mark.asyncio +async def test_an_endpoint_the_hub_should_not_fetch_is_refused(client, endpoint): + token = await _user(client, "ssrf_shape") + r = await _subscribe(client, token, endpoint) + assert r.status_code == 422, (endpoint, r.text) + + +@pytest.mark.asyncio +async def test_a_name_resolving_to_a_private_address_is_never_sent_to(): + _, p256dh, auth = _phone() + seen = [] + mock = httpx.AsyncClient(transport=httpx.MockTransport( + lambda req: seen.append(req) or httpx.Response(201))) + result = await webpush.send(webpush.Target("https://localhost/up", p256dh, auth), + {"v": 1}, ttl=60, client=mock) + assert result == webpush.FAILED and seen == [] + + +@pytest.mark.asyncio +async def test_the_request_goes_to_the_address_that_was_checked(monkeypatch): + """The hostname is the TLS name and the Host header only: a second lookup + returning something else would never be made.""" + sk, p256dh, auth = _phone() + + async def resolved(host, port): + assert host == "push.example.net" + return "93.184.215.14" + + monkeypatch.setattr(webpush, "_resolve_public", resolved) + seen = [] + mock = httpx.AsyncClient(transport=httpx.MockTransport( + lambda req: seen.append(req) or httpx.Response(201))) + result = await webpush.send( + webpush.Target("https://push.example.net/up/abc?up=1", p256dh, auth), + {"title": "hello"}, ttl=60, client=mock) + assert result == webpush.DELIVERED + (req,) = seen + assert req.url.host == "93.184.215.14" and req.url.path == "/up/abc" + assert req.headers["host"] == "push.example.net" + assert req.extensions["sni_hostname"] == "push.example.net" + assert req.headers["content-encoding"] == "aes128gcm" and req.headers["ttl"] == "60" + assert json.loads(_decrypt(req.content, sk, _b64(auth))) == {"title": "hello"} + + +@pytest.mark.asyncio +async def test_bad_keys_are_refused(client): + token = await _user(client, "bad_keys_user") + r = await client.post("/v1/push/subscriptions", json={ + "endpoint": "https://push.example.net/up/abc", + "p256dh": _b64e(b"\x04" + b"\x01" * 64), "auth": _b64e(b"0" * 16)}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 422 + + +# ── Rows ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_registering_again_updates_the_same_row(client): + token = await _user(client, "registers_again") + first = (await _subscribe(client, token)).json()["id"] + second = (await _subscribe(client, token)).json()["id"] + assert first == second + + +@pytest.mark.asyncio +async def test_an_account_holds_a_bounded_number_of_subscriptions(client): + token = await _user(client, "many_phones") + for i in range(push.MAX_SUBSCRIPTIONS): + r = await _subscribe(client, token, f"https://push.example.net/up/{i}") + assert r.status_code == 200, r.text + r = await _subscribe(client, token, "https://push.example.net/up/one-more") + assert r.status_code == 429 + + +@pytest.mark.asyncio +async def test_only_the_owner_can_remove_a_subscription(client): + mine = await _user(client, "sub_owner") + other = await _user(client, "sub_other") + sid = (await _subscribe(client, mine)).json()["id"] + r = await client.delete(f"/v1/push/subscriptions/{sid}", + headers={"Authorization": f"Bearer {other}"}) + assert r.status_code == 404 + r = await client.delete(f"/v1/push/subscriptions/{sid}", + headers={"Authorization": f"Bearer {mine}"}) + assert r.status_code == 200 + + +# ── What is pushed, and what is not ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_notification_reaches_the_phone(client, db_session, sent): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "pushed") + assert (await _subscribe(client, member)).status_code == 200 + await create_notification(db_session, uid, "chat_message", "owner posted in pushed", + link=f"#/group/{gid}", group_id=gid, aggregate=True) + await db_session.commit() + await push.drain() + (target, payload, ttl) = sent[0] + assert payload["kind"] == "chat_message" and payload["group_id"] == gid + assert payload["title"] == "owner posted in pushed" and ttl == webpush.TTL_CHAT + + +@pytest.mark.asyncio +async def test_a_muted_group_pushes_nothing(client, db_session, sent): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "hushed") + await _subscribe(client, member) + r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {member}"}) + assert r.status_code == 200 + await create_notification(db_session, uid, "chat_message", "owner posted in hushed", + group_id=gid, aggregate=True) + await db_session.commit() + await push.drain() + assert sent == [] + + +@pytest.mark.asyncio +async def test_every_notification_turned_off_pushes_nothing_and_stores_nothing( + client, db_session, sent): + """The account-wide switch was read by the interface only: rows went on + being created and hidden. With a phone told about each row, that would have + been a switch that did nothing.""" + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "silenced") + await _subscribe(client, member) + r = await client.put("/v1/users/me/preferences/notifications_disabled", + json={"value": "true"}, + headers={"Authorization": f"Bearer {member}"}) + assert r.status_code == 200 + for kind, group in (("chat_message", gid), ("group_invite", None)): + made = await create_notification(db_session, uid, kind, "something", + group_id=group, aggregate=group is not None) + assert made is None, kind + await db_session.commit() + await push.drain() + assert sent == [] + rows = (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.title == "something"))).scalars().all() + assert rows == [] + + await client.put("/v1/users/me/preferences/notifications_disabled", + json={"value": "false"}, + headers={"Authorization": f"Bearer {member}"}) + await create_notification(db_session, uid, "group_invite", "back on") + await db_session.commit() + await push.drain() + assert [p["title"] for _, p, _ in sent] == ["back on"] + + +@pytest.mark.asyncio +async def test_a_busy_conversation_reaches_a_phone_once_per_window(client, db_session, sent): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "chatty") + await _subscribe(client, member) + for i in range(5): + await create_notification(db_session, uid, "chat_message", f"line {i}", + group_id=gid, aggregate=True) + await create_notification(db_session, uid, "group_invite", "not chat") + await db_session.commit() + await push.drain() + assert [p["title"] for _, p, _ in sent] == ["line 0", "not chat"] + + +@pytest.mark.asyncio +async def test_a_registration_the_push_server_dropped_falls_back_to_fetching( + client, db_session, monkeypatch): + """The row loses its endpoint, not its existence: the phone keeps fetching.""" + from meshbay_hub.api.notifications import create_notification + + async def gone(target, payload, *, ttl, client=None): + return webpush.GONE + + monkeypatch.setattr(push, "_send", gone) + push._last_chat.clear() + member, uid, gid = await _two_in_a_group(client, db_session, "dropped") + await _subscribe(client, member) + await create_notification(db_session, uid, "group_invite", "anyone there") + await db_session.commit() + await push.drain() + db_session.expire_all() + (row,) = (await db_session.execute( + select(PushSubscription).where(PushSubscription.user_id == uid))).scalars().all() + assert row.endpoint is None and row.p256dh is None and row.poll_hash is not None + + +# ── Fetching, for a phone with no distributor ─────────────────────────────── + +async def _poll(client, reg, since=None): + return await client.post("/v1/push/poll", json={ + "id": reg["id"], "secret": reg["poll_secret"], "since": since or reg["now"]}) + + +@pytest.fixture +def unthrottled(monkeypatch): + monkeypatch.setattr(push, "POLL_MIN_INTERVAL", 0.0) + push._last_poll.clear() + + +@pytest.mark.asyncio +async def test_a_phone_without_a_distributor_fetches_what_is_new( + client, db_session, sent, unthrottled): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "fetched") + r = await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {member}"}) + assert r.status_code == 200, r.text + reg = r.json() + # The invitation that made them a member predates the registration: not news. + assert (await _poll(client, reg)).json()["notifications"] == [] + + await create_notification(db_session, uid, "chat_message", "owner posted in fetched", + link=f"#/group/{gid}", group_id=gid, aggregate=True) + await db_session.commit() + await push.drain() + assert sent == [], "a row with no endpoint is never pushed to" + (got,) = (await _poll(client, reg)).json()["notifications"] + assert got["kind"] == "chat_message" and got["group_id"] == gid + assert got["title"] == "owner posted in fetched" + assert (await _poll(client, reg, got["created_at"])).json()["notifications"] == [] + + +@pytest.mark.asyncio +async def test_fetching_honours_both_switches(client, db_session, unthrottled): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "fetchmute") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {member}"})).json() + await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {member}"}) + await create_notification(db_session, uid, "chat_message", "muted line", + group_id=gid, aggregate=True) + await client.put("/v1/users/me/preferences/notifications_disabled", + json={"value": "true"}, headers={"Authorization": f"Bearer {member}"}) + await create_notification(db_session, uid, "group_invite", "all off") + await db_session.commit() + assert (await _poll(client, reg)).json()["notifications"] == [] + + +@pytest.mark.asyncio +async def test_the_poll_secret_is_the_only_way_in(client, unthrottled): + mine = await _user(client, "poll_owner") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + assert (await _poll(client, {**reg, "poll_secret": "x" * 43})).status_code == 404 + assert (await _poll(client, {**reg, "id": "0" * 36})).status_code == 404 + again = (await client.post("/v1/push/subscriptions", json={"id": reg["id"]}, + headers={"Authorization": f"Bearer {mine}"})).json() + assert again["id"] == reg["id"] + assert (await _poll(client, reg)).status_code == 404, "a new secret retires the old" + assert (await _poll(client, again)).status_code == 200 + await client.delete(f"/v1/push/subscriptions/{reg['id']}", + headers={"Authorization": f"Bearer {mine}"}) + assert (await _poll(client, again)).status_code == 404, "signed out: the row is gone" + + +@pytest.mark.asyncio +async def test_another_account_cannot_take_over_a_row(client): + mine = await _user(client, "row_owner") + other = await _user(client, "row_taker") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + theirs = (await client.post("/v1/push/subscriptions", json={"id": reg["id"]}, + headers={"Authorization": f"Bearer {other}"})).json() + assert theirs["id"] != reg["id"] + + +@pytest.mark.asyncio +async def test_a_phone_cannot_poll_faster_than_the_floor(client): + push._last_poll.clear() + mine = await _user(client, "eager_poller") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + assert (await _poll(client, reg)).status_code == 200 + r = await _poll(client, reg) + assert r.status_code == 429 and int(r.headers["retry-after"]) > 0 + + +@pytest.mark.asyncio +async def test_a_phone_gaining_a_distributor_keeps_its_row(client): + mine = await _user(client, "upgrading_phone") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + _, p256dh, auth = _phone() + r = await client.post("/v1/push/subscriptions", json={ + "id": reg["id"], "endpoint": "https://push.example.net/up/new", + "p256dh": p256dh, "auth": auth}, headers={"Authorization": f"Bearer {mine}"}) + assert r.json()["id"] == reg["id"] + + +@pytest.mark.asyncio +async def test_an_endpoint_without_its_keys_is_refused(client): + mine = await _user(client, "keyless_phone") + r = await client.post("/v1/push/subscriptions", + json={"endpoint": "https://push.example.net/up/k"}, + headers={"Authorization": f"Bearer {mine}"}) + assert r.status_code == 422 diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py index 563dfa8..aabcf45 100644 --- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -39,6 +39,7 @@ PUBLIC = { ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped", ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits", ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped", + ("POST", "/v1/push/poll"): "a phone's own poll secret; reads notification lines, one a minute", ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp", ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout", ("GET", "/v1/groups"): "the public directory — empty when public groups are off", |