From 1bdf51cd397f38b061fae5339d96f93c1ed3dd87 Mon Sep 17 00:00:00 2001 From: Sivert Date: Wed, 7 Oct 2026 13:08:22 +0200 Subject: [PATCH] Wake phones through the push relay (GRYT-1656) The phone hands this server the capability the relay gave it, and the server posts it to the relay when there's something to wake it for: - a DM, in either chat:send or mls:send. A reaction, edit or delete in an MLS DM doesn't count. - a direct mention or a role mention, in a channel the person can read. @everyone and @here don't push, because they'd wake the whole server. Nobody gets a push while they're at a screen. That means any socket that's neither idle on the desktop (`isAFK`) nor a phone that said it went to the background (`push:presence`). One push per phone per conversation every 15 seconds, so a busy DM is one buzz. The relay URL defaults to https://push.gryt.chat. `GRYT_PUSH_RELAY_URL=off` turns pushes off, and it only takes https, or http to localhost. The server picks the relay, never the phone, so a phone can't point the server at an arbitrary URL. A 404 or 410 from the relay deletes the capability here too. Pushing is fire and forget and runs after delivery, so a slow or dead relay can't hold up a message. New table `push_devices`, keyed on (server_user_id, install_id), at most ten per account. Merging a guest into an account drops the guest's rows, and the phone registers again as the account when it reconnects. Review-required (src/db). Things to look at: - The mention filter in chat.ts. User mentions are checked with `mayViewChannel`, and role mentions were already limited to readers by `massMentionAudience`. - `isPresent`: a desktop that's open but not idle blocks phone pushes. That's the behaviour I picked, and it's easy to change. - Channel mutes live on the phone only, so a mention in a channel you muted still pushes. Follow-up: GRYT-1689. Checked locally with the phone branch and the relay. The simulator registered and the server stored the capability. A guest's "@Thunder" with the phone in the background reached the relay, which sent it on to Apple's sandbox. Apple refused it only because the test key was made up. The two messages before it, with no mention, didn't push. 2,002 tests pass, including two guard tests this tripped: the guest merge and the client-record allowlist. Co-Authored-By: Claude Opus 5.5 --- dist-selfhosted/config.env | 3 + src/db/sqlite/connection.ts | 11 ++ src/db/sqlite/index.ts | 1 + src/db/sqlite/mergeGuest.test.ts | 2 +- src/db/sqlite/mergeGuest.ts | 2 + src/db/sqlite/pushDevices.test.ts | 39 +++++++ src/db/sqlite/pushDevices.ts | 39 +++++++ src/services/push.test.ts | 104 ++++++++++++++++++ src/services/push.ts | 123 ++++++++++++++++++++++ src/socket/handlers/chat.ts | 26 ++++- src/socket/handlers/mls.ts | 7 ++ src/socket/handlers/push.ts | 70 ++++++++++++ src/socket/index.ts | 2 + src/socket/utils/publicClientList.test.ts | 1 + src/types.ts | 2 + 15 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 src/db/sqlite/pushDevices.test.ts create mode 100644 src/db/sqlite/pushDevices.ts create mode 100644 src/services/push.test.ts create mode 100644 src/services/push.ts create mode 100644 src/socket/handlers/push.ts diff --git a/dist-selfhosted/config.env b/dist-selfhosted/config.env index 5c3f7cdd..1cd335f4 100644 --- a/dist-selfhosted/config.env +++ b/dist-selfhosted/config.env @@ -41,6 +41,9 @@ METRICS_PORT=0 # IMAGE_WORKER_POLL_MS=1000 # ── Optional settings ───────────────────────────────────────────── +# Phone notifications go through push.gryt.chat, which sees a random ID and "new message", +# never who or what. Set to off to turn them off, or to your own relay's https URL. +# GRYT_PUSH_RELAY_URL=https://push.gryt.chat # CORS_ORIGIN=* # JWT_SECRET=change-me-to-something-random # REFRESH_TOKEN_TTL_DAYS=7 diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 8fba81fe..7b8ce131 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -247,6 +247,17 @@ function createSchema(d: DatabaseSync): void { updated_at TEXT NOT NULL ); + -- Phones to wake through the push relay (GRYT-1656). The capability is all the + -- relay needs; the server never sees the Apple or Google token behind it. + CREATE TABLE IF NOT EXISTS push_devices ( + server_user_id TEXT NOT NULL, + install_id TEXT NOT NULL, + capability TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (server_user_id, install_id) + ); + -- Friends on this server (GRYT-1471), one row per pair with the smaller id -- first. Each person's whole list is on their own devices. CREATE TABLE IF NOT EXISTS friendships ( diff --git a/src/db/sqlite/index.ts b/src/db/sqlite/index.ts index 13c6c3b2..a171d342 100644 --- a/src/db/sqlite/index.ts +++ b/src/db/sqlite/index.ts @@ -19,6 +19,7 @@ export * from "./reports"; export * from "./userReports"; export * from "./blocks"; export * from "./contactPrefs"; +export * from "./pushDevices"; export * from "./friends"; export * from "./mentions"; export * from "./webhooks"; diff --git a/src/db/sqlite/mergeGuest.test.ts b/src/db/sqlite/mergeGuest.test.ts index 3e97019f..ebe853a7 100644 --- a/src/db/sqlite/mergeGuest.test.ts +++ b/src/db/sqlite/mergeGuest.test.ts @@ -217,7 +217,7 @@ describe("merging a guest into an account that is already a member", () => { "users.server_user_id", "roles.server_user_id", "conversation_members.server_user_id", "mentions.server_user_id", "refresh_tokens.server_user_id", "mls_devices.server_user_id", "mls_key_packages.server_user_id", "mls_welcomes.server_user_id", - "mls_removed_devices.server_user_id", + "mls_removed_devices.server_user_id", "push_devices.server_user_id", ]); const listed = new Set(AUTHOR_COLUMNS.map(([t, c]) => `${t}.${c}`)); const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all() as { name: string }[]; diff --git a/src/db/sqlite/mergeGuest.ts b/src/db/sqlite/mergeGuest.ts index babb5b49..cf0acb78 100644 --- a/src/db/sqlite/mergeGuest.ts +++ b/src/db/sqlite/mergeGuest.ts @@ -147,6 +147,8 @@ function mergeInTransaction( ); db.prepare(`UPDATE refresh_tokens SET revoked = 1 WHERE gryt_user_id = ? AND revoked = 0`).run(guestGrytUserId); + // The phone registers again as the account when it reconnects. + db.prepare(`DELETE FROM push_devices WHERE server_user_id = ?`).run(from); db.prepare(`DELETE FROM users WHERE server_user_id = ?`).run(from); return { guestServerUserId: from, accountServerUserId: to, ownerMoved, conversationIds }; diff --git a/src/db/sqlite/pushDevices.test.ts b/src/db/sqlite/pushDevices.test.ts new file mode 100644 index 00000000..7702cb85 --- /dev/null +++ b/src/db/sqlite/pushDevices.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import { initSqlite } from "./connection"; +import { listPushDevices, MAX_PUSH_DEVICES_PER_USER, removePushCapability, removePushDevice, savePushDevice } from "./pushDevices"; + +const cap = (c: string) => `p_${c.repeat(43)}`; + +describe("push devices", () => { + const dir = mkdtempSync(join(tmpdir(), "gryt-push-")); + before(async () => { + process.env.DATA_DIR = dir; + await initSqlite(); + }); + after(() => rmSync(dir, { recursive: true, force: true })); + + it("keeps one row per install and replaces its capability", () => { + savePushDevice("u1", "install-1", cap("a")); + savePushDevice("u1", "install-1", cap("b")); + assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b") }]); + removePushDevice("u1", "install-1"); + assert.deepEqual(listPushDevices("u1"), []); + }); + + it("drops a dead capability from every account that had it", () => { + savePushDevice("u2", "install-2", cap("c")); + savePushDevice("u3", "install-2", cap("c")); + removePushCapability(cap("c")); + assert.deepEqual([...listPushDevices("u2"), ...listPushDevices("u3")], []); + }); + + it("holds at most ten phones per account", () => { + for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER + 3; i++) savePushDevice("u4", `install-${String(i).padStart(2, "0")}`, cap("d")); + assert.equal(listPushDevices("u4").length, MAX_PUSH_DEVICES_PER_USER); + }); +}); diff --git a/src/db/sqlite/pushDevices.ts b/src/db/sqlite/pushDevices.ts new file mode 100644 index 00000000..33325fb4 --- /dev/null +++ b/src/db/sqlite/pushDevices.ts @@ -0,0 +1,39 @@ +import { getSqliteDb, toIso } from "./connection"; + +/** A phone that asked to be woken for this account (GRYT-1656). */ +export interface PushDevice { + installId: string; + capability: string; +} + +/** More than this and the oldest goes. Nobody has ten phones; a reinstall loop might. */ +export const MAX_PUSH_DEVICES_PER_USER = 10; + +export function savePushDevice(serverUserId: string, installId: string, capability: string): void { + const db = getSqliteDb(); + const now = toIso(new Date()); + db.prepare( + `INSERT INTO push_devices (server_user_id, install_id, capability, created_at, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(server_user_id, install_id) DO UPDATE SET capability = excluded.capability, updated_at = excluded.updated_at`, + ).run(serverUserId, installId, capability, now, now); + db.prepare( + `DELETE FROM push_devices WHERE server_user_id = ? AND install_id NOT IN ( + SELECT install_id FROM push_devices WHERE server_user_id = ? ORDER BY updated_at DESC, install_id LIMIT ?)`, + ).run(serverUserId, serverUserId, MAX_PUSH_DEVICES_PER_USER); +} + +export function listPushDevices(serverUserId: string): PushDevice[] { + const rows = getSqliteDb() + .prepare(`SELECT install_id, capability FROM push_devices WHERE server_user_id = ?`) + .all(serverUserId) as { install_id: string; capability: string }[]; + return rows.map((r) => ({ installId: r.install_id, capability: r.capability })); +} + +export function removePushDevice(serverUserId: string, installId: string): void { + getSqliteDb().prepare(`DELETE FROM push_devices WHERE server_user_id = ? AND install_id = ?`).run(serverUserId, installId); +} + +/** The relay said the phone is gone, so every account on it here stops trying. */ +export function removePushCapability(capability: string): void { + getSqliteDb().prepare(`DELETE FROM push_devices WHERE capability = ?`).run(capability); +} diff --git a/src/services/push.test.ts b/src/services/push.test.ts new file mode 100644 index 00000000..d06b8042 --- /dev/null +++ b/src/services/push.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { PushDevice } from "../db"; +import type { Clients } from "../types"; +import { createPusher, isPresent, readPushRelay } from "./push"; + +const CAP_A = `p_${"a".repeat(43)}`; +const CAP_B = `p_${"b".repeat(43)}`; + +function client(serverUserId: string, extra: Partial = {}): Clients[string] { + return { serverUserId, isAFK: false, ...extra } as Clients[string]; +} + +function harness(devices: Record, status = 202) { + const calls: { url: string; auth: string; body: string }[] = []; + const forgotten: string[] = []; + let now = 1_000_000; + const pusher = createPusher({ + relay: "https://push.test", + listDevices: (id) => devices[id] ?? [], + forget: (cap) => forgotten.push(cap), + fetch: (async (url: string, init: RequestInit) => { + calls.push({ url, auth: (init.headers as Record).authorization, body: String(init.body) }); + return new Response(null, { status }); + }) as unknown as typeof fetch, + now: () => now, + }); + return { pusher, calls, forgotten, tick: (ms: number) => (now += ms) }; +} + +const flush = () => new Promise((r) => setImmediate(r)); + +describe("push relay URL", () => { + it("defaults to the public relay and can be turned off", () => { + assert.equal(readPushRelay({}), "https://push.gryt.chat"); + assert.equal(readPushRelay({ GRYT_PUSH_RELAY_URL: "off" }), null); + assert.equal(readPushRelay({ GRYT_PUSH_RELAY_URL: "" }), null); + assert.equal(readPushRelay({ GRYT_PUSH_RELAY_URL: "https://push.example.org/" }), "https://push.example.org"); + }); + + it("refuses plain http except to this machine", () => { + assert.equal(readPushRelay({ GRYT_PUSH_RELAY_URL: "http://push.example.org" }), null); + assert.equal(readPushRelay({ GRYT_PUSH_RELAY_URL: "http://localhost:8080" }), "http://localhost:8080"); + }); +}); + +describe("who is at a screen", () => { + it("counts a socket that is neither idle nor a backgrounded phone", () => { + assert.equal(isPresent({ a: client("u1") }, "u1"), true); + assert.equal(isPresent({ a: client("u1", { isAFK: true }) }, "u1"), false); + assert.equal(isPresent({ a: client("u1", { appInBackground: true }) }, "u1"), false); + assert.equal(isPresent({ a: client("u1", { appInBackground: true }), b: client("u1") }, "u1"), true); + assert.equal(isPresent({ a: client("u2") }, "u1"), false); + }); +}); + +describe("pushing", () => { + it("sends the capability in the header and only the kind in the body", async () => { + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }); + h.pusher.notify({}, ["u1"], "dm", "conv"); + await flush(); + assert.deepEqual(h.calls, [{ url: "https://push.test/v1/push", auth: `Bearer ${CAP_A}`, body: JSON.stringify({ kind: "dm" }) }]); + }); + + it("skips somebody who is at a screen, and wakes them once they put the phone away", async () => { + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }); + h.pusher.notify({ s: client("u1") }, ["u1"], "mention", "conv"); + await flush(); + assert.equal(h.calls.length, 0); + h.pusher.notify({ s: client("u1", { appInBackground: true }) }, ["u1"], "mention", "conv"); + await flush(); + assert.equal(h.calls.length, 1); + }); + + it("buzzes once per phone per conversation in fifteen seconds", async () => { + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }, { installId: "tablet-12", capability: CAP_B }] }); + h.pusher.notify({}, ["u1"], "dm", "conv"); + h.pusher.notify({}, ["u1", "u1"], "dm", "conv"); + h.pusher.notify({}, ["u1"], "dm", "other"); + h.tick(15_000); + h.pusher.notify({}, ["u1"], "dm", "conv"); + await flush(); + assert.equal(h.calls.length, 6); + }); + + it("forgets a capability the relay calls gone or unknown", async () => { + for (const status of [404, 410]) { + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }, status); + h.pusher.notify({}, ["u1"], "dm", "conv"); + await flush(); + await flush(); + assert.deepEqual(h.forgotten, [CAP_A]); + } + }); + + it("keeps it when the relay is only busy", async () => { + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }, 502); + h.pusher.notify({}, ["u1"], "dm", "conv"); + await flush(); + await flush(); + assert.deepEqual(h.forgotten, []); + }); +}); diff --git a/src/services/push.ts b/src/services/push.ts new file mode 100644 index 00000000..aad24e11 --- /dev/null +++ b/src/services/push.ts @@ -0,0 +1,123 @@ +import consola from "consola"; + +import { listPushDevices, removePushCapability, type PushDevice } from "../db"; +import type { Clients } from "../types"; + +/** + * Waking phones through the push relay (GRYT-1656). The relay gets a capability and + * a kind, nothing else: it writes the notification text itself. + */ + +export type PushKind = "mention" | "dm"; + +export const DEFAULT_PUSH_RELAY = "https://push.gryt.chat"; + +/** Same shape the relay hands out. Anything else is refused before it is stored. */ +export const CAPABILITY_SHAPE = /^p_[A-Za-z0-9_-]{43}$/; + +/** One buzz per phone per conversation in this window. A busy DM is one notification, not twenty. */ +const QUIET_MS = 15_000; +const TIMEOUT_MS = 5_000; + +/** Unset means the public relay. "off" or empty turns pushes off for this server. */ +export function readPushRelay(env: NodeJS.ProcessEnv = process.env): string | null { + const raw = env.GRYT_PUSH_RELAY_URL; + if (raw === undefined) return DEFAULT_PUSH_RELAY; + const value = raw.trim(); + if (value === "" || value.toLowerCase() === "off") return null; + try { + const url = new URL(value); + const local = url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw new Error("not https"); + return url.origin + url.pathname.replace(/\/+$/, ""); + } catch { + consola.warn(`GRYT_PUSH_RELAY_URL "${value}" is not an https URL, so pushes are off`); + return null; + } +} + +/** Somebody at a screen: a socket that is neither idle on the desktop nor a phone in a pocket. */ +export function isPresent(clientsInfo: Clients, serverUserId: string): boolean { + return Object.values(clientsInfo).some( + (c) => c?.serverUserId === serverUserId && !c.isAFK && !c.appInBackground, + ); +} + +export interface PusherDeps { + relay: string | null; + listDevices: (serverUserId: string) => PushDevice[]; + forget: (capability: string) => void; + fetch: typeof fetch; + now: () => number; +} + +export function createPusher(deps: PusherDeps) { + const lastSent = new Map(); + + async function send(device: PushDevice, kind: PushKind): Promise { + try { + const res = await deps.fetch(`${deps.relay}/v1/push`, { + method: "POST", + headers: { authorization: `Bearer ${device.capability}`, "content-type": "application/json" }, + body: JSON.stringify({ kind }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + // 404: the relay forgot it. 410: Apple or Google said the phone is gone. + if (res.status === 404 || res.status === 410) deps.forget(device.capability); + else if (!res.ok && res.status !== 429) consola.debug(`push relay answered ${res.status}`); + } catch (err) { + consola.debug("push relay unreachable", (err as Error).message); + } + } + + /** Fire and forget: called after delivery, and never awaited by a send. */ + function notify(clientsInfo: Clients, serverUserIds: Iterable, kind: PushKind, conversationId: string): void { + if (!deps.relay) return; + const now = deps.now(); + for (const serverUserId of new Set(serverUserIds)) { + if (isPresent(clientsInfo, serverUserId)) continue; + let devices: PushDevice[]; + try { + devices = deps.listDevices(serverUserId); + } catch (err) { + consola.warn("listing push devices failed", err); + continue; + } + for (const device of devices) { + const key = `${device.capability}:${conversationId}`; + if (now - (lastSent.get(key) ?? 0) < QUIET_MS) continue; + lastSent.set(key, now); + void send(device, kind); + } + } + if (lastSent.size > 5_000) { + for (const [key, at] of lastSent) if (now - at >= QUIET_MS) lastSent.delete(key); + } + } + + return { notify }; +} + +let shared: ReturnType | null = null; +let relay: string | null | undefined; + +/** Read once, so a bad value warns once rather than on every message. */ +function configuredRelay(): string | null { + if (relay === undefined) relay = readPushRelay(); + return relay; +} + +export function pushNotify(clientsInfo: Clients, serverUserIds: Iterable, kind: PushKind, conversationId: string): void { + shared ??= createPusher({ + relay: configuredRelay(), + listDevices: listPushDevices, + forget: removePushCapability, + fetch, + now: Date.now, + }); + shared.notify(clientsInfo, serverUserIds, kind, conversationId); +} + +export function pushEnabled(): boolean { + return configuredRelay() !== null; +} diff --git a/src/socket/handlers/chat.ts b/src/socket/handlers/chat.ts index 3333e652..a5d165a6 100644 --- a/src/socket/handlers/chat.ts +++ b/src/socket/handlers/chat.ts @@ -57,9 +57,10 @@ import { textMuteError, textMuteFor } from "../../moderation/textMute"; import { MESSAGE_MAX_LENGTH, MESSAGE_TOO_LONG, SEALED_MAX_LENGTH } from "../../utils/messageLimits"; import { applyAutoRoles } from "../../services/autoRoles"; import { findMentions, type MentionableMember } from "../../services/mentions"; +import { pushNotify } from "../../services/push"; import { canonicalizeMentions, type CanonicalMentions, type MentionRights } from "../../services/mentionSyntax"; import { massMentionAudience } from "../../services/massMentions"; -import { mayInChannel, visibleChannelIds } from "../../services/channelPermissions"; +import { mayInChannel, mayViewChannel, visibleChannelIds } from "../../services/channelPermissions"; import { fileReadVerdict } from "../../services/fileAccess"; import { pluginEvents } from "../../plugins"; import { deleteMessageEverywhere } from "../../moderation/deleteMessage"; @@ -737,6 +738,16 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { It still sent this, and without the echo its row never settles. */ if (nonce && !recipients.includes(clientId)) socket.emit("chat:new", { ...enriched, nonce }); + if (access.kind === "dm") { + try { + const blockers = await blockersOfSender(auth.tokenPayload.serverUserId); + const others = access.memberIds.filter((id) => id !== auth.tokenPayload.serverUserId && !blockers.has(id)); + pushNotify(clientsInfo, others, "dm", created.conversation_id); + } catch (err) { + consola.warn("dm push failed", created.message_id, err); + } + } + // The root's "N replies" summary and the thread's activity sort ride on // this, sent to the same audience that got the message. GRYT-981. if (threadUpdate) { @@ -810,6 +821,19 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { kind, }); } + + /* Direct and role mentions wake a phone. @everyone and @here would wake + the whole server, and a DM already pushed as a DM. */ + if (access.kind !== "dm") { + const blockers = await blockersOfSender(auth.tokenPayload.serverUserId); + const woken: string[] = []; + for (const [id, kind] of kindOf) { + if ((kind !== "user" && kind !== "role") || blockers.has(id)) continue; + if (kind === "user" && !(await mayViewChannel(created.conversation_id, id))) continue; + woken.push(id); + } + pushNotify(clientsInfo, woken, "mention", created.conversation_id); + } } catch (err) { consola.warn("recording mentions failed", created.message_id, err); } diff --git a/src/socket/handlers/mls.ts b/src/socket/handlers/mls.ts index d7c1f237..b9e463e8 100644 --- a/src/socket/handlers/mls.ts +++ b/src/socket/handlers/mls.ts @@ -42,6 +42,7 @@ import { textMuteError, textMuteFor } from "../../moderation/textMute"; import { mayInChannel } from "../../services/channelPermissions"; import { fileReadVerdict } from "../../services/fileAccess"; import { asBytes, parseGroupMessage, parseKeyPackage, parseWelcome } from "../../services/mlsWire"; +import { pushNotify } from "../../services/push"; import { SEALED_MAX_LENGTH } from "../../utils/messageLimits"; import { appendCachedMessage } from "../utils/messageCache"; import { formatMlsPlaceholder, SYSTEM_SENDER_ID } from "../utils/systemMessages"; @@ -706,6 +707,12 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap { }, payload.conversationId); // A reaction, edit or delete says false, or old apps show a line for each one. if (application && payload.placeholder !== false) { + try { + const blockers = await blockersOfSender(self); + pushNotify(clientsInfo, memberIds.filter((id) => id !== self && !blockers.has(id)), "dm", payload.conversationId); + } catch (err) { + consola.warn("mls push failed", payload.conversationId, err); + } await writePlaceholder(self, memberIds, payload.conversationId, result.seq, result.createdAt).catch((err) => consola.warn("mls placeholder failed", payload.conversationId, err), ); diff --git a/src/socket/handlers/push.ts b/src/socket/handlers/push.ts new file mode 100644 index 00000000..81e731a7 --- /dev/null +++ b/src/socket/handlers/push.ts @@ -0,0 +1,70 @@ +import consola from "consola"; + +import { removePushDevice, savePushDevice } from "../../db"; +import { CAPABILITY_SHAPE, pushEnabled } from "../../services/push"; +import { checkRateLimit, RateLimitRule } from "../../utils/rateLimiter"; +import { requireAuth } from "../middleware/auth"; +import type { EventHandlerMap, HandlerContext } from "./types"; + +/** + * A phone hands over the capability the relay gave it for this server, and says + * when it goes to the background (GRYT-1656). + */ + +type Reply = { ok: true } | { ok: false; error: string }; +type Ack = (reply: Reply) => void; + +const INSTALL_ID = /^[A-Za-z0-9_-]{8,64}$/; +const RL_REGISTER: RateLimitRule = { limit: 20, windowMs: 60_000, scorePerAction: 1, maxScore: 12, scoreDecayMs: 3_000 }; + +export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { + const { socket, clientId, clientsInfo, getClientIp } = ctx; + + function limited(event: string): boolean { + return !checkRateLimit(event, clientsInfo[clientId]?.serverUserId, getClientIp(), RL_REGISTER).allowed; + } + + return { + "push:register": async (payload: { accessToken: string; installId?: unknown; capability?: unknown }, ack: Ack) => { + ack = typeof ack === "function" ? ack : () => {}; + try { + if (!pushEnabled()) return ack({ ok: false, error: "push_off" }); + if (limited("push:register")) return ack({ ok: false, error: "rate_limited" }); + if (!payload || typeof payload.installId !== "string" || !INSTALL_ID.test(payload.installId) + || typeof payload.capability !== "string" || !CAPABILITY_SHAPE.test(payload.capability)) { + return ack({ ok: false, error: "invalid_payload" }); + } + const auth = await requireAuth(socket, payload); + if (!auth) return ack({ ok: false, error: "unauthorized" }); + savePushDevice(auth.tokenPayload.serverUserId, payload.installId, payload.capability); + ack({ ok: true }); + } catch (err) { + consola.error("push:register failed", err); + ack({ ok: false, error: "failed" }); + } + }, + + "push:unregister": async (payload: { accessToken: string; installId?: unknown }, ack: Ack) => { + ack = typeof ack === "function" ? ack : () => {}; + try { + if (limited("push:unregister")) return ack({ ok: false, error: "rate_limited" }); + if (!payload || typeof payload.installId !== "string" || !INSTALL_ID.test(payload.installId)) { + return ack({ ok: false, error: "invalid_payload" }); + } + const auth = await requireAuth(socket, payload); + if (!auth) return ack({ ok: false, error: "unauthorized" }); + removePushDevice(auth.tokenPayload.serverUserId, payload.installId); + ack({ ok: true }); + } catch (err) { + consola.error("push:unregister failed", err); + ack({ ok: false, error: "failed" }); + } + }, + + /* Only this socket's own flag, so no token: lying about it only changes whether you get pushed. */ + "push:presence": (payload: { background?: unknown }) => { + const info = clientsInfo[clientId]; + if (info && typeof payload?.background === "boolean") info.appInBackground = payload.background; + }, + }; +} diff --git a/src/socket/index.ts b/src/socket/index.ts index 2f7984ee..16ae8e0a 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -47,6 +47,7 @@ import { registerVoiceLatencyHandlers } from "./handlers/voiceLatency"; import { registerReportHandlers } from "./handlers/reports"; import { registerBlockHandlers } from "./handlers/blocks"; import { registerContactPrefsHandlers } from "./handlers/contactPrefs"; +import { registerPushHandlers } from "./handlers/push"; import { registerFriendHandlers } from "./handlers/friends"; import { registerTypingHandlers } from "./handlers/typing"; import { registerPluginHandlers } from "./handlers/plugins"; @@ -352,6 +353,7 @@ export function socketHandler(io: Server, socket: Socket, sfuClient: SFUClient | ...registerMlsHandlers(ctx), ...registerMlsPersonKeyHandlers(ctx), ...registerMentionHandlers(ctx), + ...registerPushHandlers(ctx), }; // ── Base socket events ─────────────────────────────────────── diff --git a/src/socket/utils/publicClientList.test.ts b/src/socket/utils/publicClientList.test.ts index b8245bf9..eb61ff4c 100644 --- a/src/socket/utils/publicClientList.test.ts +++ b/src/socket/utils/publicClientList.test.ts @@ -44,6 +44,7 @@ const everything: Required = { isServerDeafened: true, activity: "Playing something", richActivity: { type: "playing", name: "Card game", state: "Round two" }, + appInBackground: true, status: "in_voice", lastSeen: new Date(0), accessToken: "eyJhbGciOiJIUzI1NiJ9.alice.token", diff --git a/src/types.ts b/src/types.ts index df422a86..0363a418 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,8 @@ export interface Clients { activity?: string; /** The Rich Presence card, checked by `normaliseRichActivity`. Never set without `activity`. */ richActivity?: import("./utils/richActivity").RichActivity; + /** The phone says it went to the background, so it should be woken by push. */ + appInBackground?: boolean; status?: UserStatus; lastSeen?: Date; accessToken?: string; // JWT access token for this server