feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.
- Schema: push_devices (per-device endpoint) + notification_subscriptions
(per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
streams (news.post, server.status, idoc.warning, champ.start, governor.election)
drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
(vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
to each subscribed device; never throws. Two producers — shardIngest.ingest
(beside the SSE broadcast) and the create/publish-post path (news.post).
Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
/auth/me/devices, DELETE /auth/me/devices/:id, GET
/auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
no published host port, anonymous unguessable topics (no accounts) — zero
interactive setup. No publish token required (content-free design); optional
NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
content-free payload) + notifications route auth gate. Full suite green (247).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
167
server/src/config/notificationStreams.js
Normal file
167
server/src/config/notificationStreams.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
||||
//
|
||||
// The single source of truth for which streams a user can subscribe to, and how
|
||||
// a shard event maps onto them. Two families:
|
||||
// • public / opt-in — no linked game account required; delivered to every
|
||||
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||
// can never produce a public push.
|
||||
// • personal / owner-keyed — require a linked game account; delivered ONLY to
|
||||
// the owning user's devices (resolved from the event's
|
||||
// game account via shardLinks), never fanned out publicly.
|
||||
//
|
||||
// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref });
|
||||
// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the
|
||||
// real, ownership-checked content over the authenticated API. So even a leaked
|
||||
// ntfy topic reveals nothing (docs/android/PLAN.md §11).
|
||||
|
||||
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||
|
||||
// The subscribable catalog. `news.post` is produced by the website's own posts
|
||||
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
|
||||
// case; every other stream is shard-derived below.
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'server.status',
|
||||
label: 'Server up / down',
|
||||
description: 'The shard comes online or goes offline.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'idoc.warning',
|
||||
label: 'IDOC warnings',
|
||||
description: 'A house falls into its final (IDOC) decay stage.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'champ.start',
|
||||
label: 'Champion spawn starts',
|
||||
description: 'A champion spawn becomes active.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'governor.election',
|
||||
label: 'Governor elections',
|
||||
description: 'A town elects a new governor.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'vendor.sale',
|
||||
label: 'Your vendor sold an item',
|
||||
description: 'One of your player vendors made a sale.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
{
|
||||
id: 'house.idoc',
|
||||
label: 'Your house entered IDOC',
|
||||
description: 'One of your houses fell into its final decay stage.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
{
|
||||
id: 'account.login',
|
||||
label: 'A login to your account',
|
||||
description: 'An authentication attempt against your game account.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
]
|
||||
|
||||
const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
||||
const isValidStream = (id) => STREAM_IDS.has(id)
|
||||
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||
// are upserts, not discrete "started"/"elected" events — see docs/link
|
||||
// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests
|
||||
// pass a fresh tracker; a module-level default backs the live dispatcher.
|
||||
function createTracker() {
|
||||
return { champActive: new Map(), cityGovernor: new Map() }
|
||||
}
|
||||
const defaultTracker = createTracker()
|
||||
|
||||
// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }).
|
||||
// May yield 0, 1, or 2 targets (an owner house.decay produces both the public
|
||||
// idoc.warning and the personal house.idoc). Pure given `tracker`.
|
||||
function mapShardEvent(event, tracker = defaultTracker) {
|
||||
if (!event || typeof event.kind !== 'string') return []
|
||||
const kind = event.kind
|
||||
const out = []
|
||||
|
||||
switch (kind) {
|
||||
case 'server.hello':
|
||||
out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
|
||||
break
|
||||
case 'server.shutdown':
|
||||
case 'server.crashed':
|
||||
out.push({ streamId: 'server.status', ref: 'down' })
|
||||
break
|
||||
case 'house.decay': {
|
||||
if (String(event.to).toUpperCase() !== 'IDOC') break
|
||||
const ref = String(event.serial ?? '')
|
||||
out.push({ streamId: 'idoc.warning', ref }) // public — location only
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'champ.update': {
|
||||
const { serial } = event
|
||||
if (serial == null) break
|
||||
const wasActive = tracker.champActive.get(serial) === true
|
||||
const isActive = event.active === true
|
||||
tracker.champActive.set(serial, isActive)
|
||||
if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
|
||||
break
|
||||
}
|
||||
case 'champ.remove':
|
||||
if (event.serial != null) tracker.champActive.delete(event.serial)
|
||||
break
|
||||
case 'city.update': {
|
||||
const { city } = event
|
||||
if (!city) break
|
||||
const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
|
||||
const prev = tracker.cityGovernor.get(city)
|
||||
tracker.cityGovernor.set(city, gov)
|
||||
// Only a real transition to a new governor, and never on first sight
|
||||
// (prev === undefined) so a reconnect snapshot isn't read as an election.
|
||||
if (prev !== undefined && gov && gov !== prev) {
|
||||
out.push({ streamId: 'governor.election', ref: String(city) })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'vendor.sale':
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
|
||||
}
|
||||
break
|
||||
case 'account.login.attempt':
|
||||
if (event.acct) {
|
||||
out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe
|
||||
// kind. Personal targets are owner-keyed and delivered solely to the owner, so
|
||||
// they are exempt from the public allowlist (that is the whole point of the
|
||||
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||
// even if a future mapping case is added carelessly.
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
16
server/src/model/notificationSubs/notificationSubs.db.js
Normal file
16
server/src/model/notificationSubs/notificationSubs.db.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query('SELECT stream_id FROM notification_subscriptions WHERE user_id = ? ORDER BY stream_id', [userId])
|
||||
|
||||
// Replace the user's whole subscription set in one transaction-ish pass: delete
|
||||
// all, then insert the new set. Called by PUT — the request body is the complete
|
||||
// desired set. `streams` is already validated against the catalog by the model.
|
||||
async function replaceForUser(userId, streams) {
|
||||
await query('DELETE FROM notification_subscriptions WHERE user_id = ?', [userId])
|
||||
for (const streamId of streams) {
|
||||
await query('INSERT INTO notification_subscriptions (user_id, stream_id) VALUES (?, ?)', [userId, streamId])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listByUser, replaceForUser }
|
||||
18
server/src/model/notificationSubs/notificationSubs.model.js
Normal file
18
server/src/model/notificationSubs/notificationSubs.model.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Per-user push-notification subscriptions (which streams a user opted into;
|
||||
// applied to every device they register). The catalog is config/notificationStreams.
|
||||
|
||||
const db = require('./notificationSubs.db')
|
||||
const { isValidStream } = require('../../config/notificationStreams')
|
||||
|
||||
const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)
|
||||
|
||||
// Replace the user's subscription set. Ignores unknown ids and de-dupes, so a
|
||||
// stale client can't create rows for streams that no longer exist. Returns the
|
||||
// stored (cleaned) set.
|
||||
async function setForUser(userId, streams) {
|
||||
const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))]
|
||||
await db.replaceForUser(userId, clean)
|
||||
return clean
|
||||
}
|
||||
|
||||
module.exports = { getForUser, setForUser }
|
||||
51
server/src/model/pushDevices/pushDevices.db.js
Normal file
51
server/src/model/pushDevices/pushDevices.db.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, user_id, transport, endpoint, platform, created_at, last_seen_at'
|
||||
|
||||
// Register (idempotent upsert on the UNIQUE user_id+endpoint). Re-registering the
|
||||
// same endpoint refreshes transport/platform and bumps last_seen_at.
|
||||
async function upsert({ userId, transport, endpoint, platform }) {
|
||||
await query(
|
||||
`INSERT INTO push_devices (user_id, transport, endpoint, platform)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE transport = VALUES(transport), platform = VALUES(platform), last_seen_at = CURRENT_TIMESTAMP`,
|
||||
[userId, transport, endpoint, platform || null],
|
||||
)
|
||||
return getByUserEndpoint(userId, endpoint)
|
||||
}
|
||||
|
||||
async function getByUserEndpoint(userId, endpoint) {
|
||||
const rows = await query(`SELECT ${COLS} FROM push_devices WHERE user_id = ? AND endpoint = ? LIMIT 1`, [userId, endpoint])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM push_devices WHERE user_id = ? ORDER BY created_at DESC`, [userId])
|
||||
|
||||
async function remove(id, userId) {
|
||||
const res = await query('DELETE FROM push_devices WHERE id = ? AND user_id = ?', [id, userId])
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Endpoints of every device whose user subscribes to `streamId` (public fan-out).
|
||||
const endpointsForStream = (streamId) =>
|
||||
query(
|
||||
`SELECT d.endpoint, d.transport
|
||||
FROM push_devices d
|
||||
JOIN notification_subscriptions s ON s.user_id = d.user_id
|
||||
WHERE s.stream_id = ?`,
|
||||
[streamId],
|
||||
)
|
||||
|
||||
// Endpoints of ONE user's devices, only if that user subscribes to `streamId`
|
||||
// (personal / owner-keyed fan-out).
|
||||
const endpointsForUserStream = (userId, streamId) =>
|
||||
query(
|
||||
`SELECT d.endpoint, d.transport
|
||||
FROM push_devices d
|
||||
JOIN notification_subscriptions s ON s.user_id = d.user_id
|
||||
WHERE d.user_id = ? AND s.stream_id = ?`,
|
||||
[userId, streamId],
|
||||
)
|
||||
|
||||
module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream }
|
||||
32
server/src/model/pushDevices/pushDevices.model.js
Normal file
32
server/src/model/pushDevices/pushDevices.model.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Registered push endpoints (Android/UnifiedPush v1). A device belongs to one
|
||||
// user; the endpoint is the distributor URL the app's ntfy topic was handed. The
|
||||
// endpoint is validated (HTTPS + allowed origin) by utils/pushDispatch before it
|
||||
// is ever stored or published to — see that module's isAllowedEndpoint.
|
||||
|
||||
const db = require('./pushDevices.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
transport: row.transport,
|
||||
endpoint: row.endpoint,
|
||||
platform: row.platform || null,
|
||||
createdAt: row.created_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}
|
||||
}
|
||||
|
||||
const register = async ({ userId, transport, endpoint, platform }) =>
|
||||
toSafe(await db.upsert({ userId, transport, endpoint, platform }))
|
||||
|
||||
const listForUser = async (userId) => (await db.listByUser(userId)).map(toSafe)
|
||||
|
||||
// Returns true if a row was deleted (the device existed and belonged to userId).
|
||||
const remove = async (id, userId) => (await db.remove(id, userId)) > 0
|
||||
|
||||
// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped).
|
||||
const endpointsForStream = (streamId) => db.endpointsForStream(streamId)
|
||||
const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId)
|
||||
|
||||
module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream }
|
||||
@@ -5,6 +5,7 @@ const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
@@ -22,12 +23,23 @@ const log = require('../../../utils/logger')('admin')
|
||||
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
|
||||
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
||||
async function announceIfNewlyPublished(post, transition) {
|
||||
await announceJobs.enqueueIfNeeded(post, transition)
|
||||
// enqueueIfNeeded returns a truthy job id EXACTLY on a real transition into
|
||||
// published news (and null on an edit/re-publish or a hiccup) — reuse that as
|
||||
// the single "newly published news" signal for the push too, so we never
|
||||
// double-fire on edits or replicate the transition logic.
|
||||
const jobId = await announceJobs.enqueueIfNeeded(post, transition)
|
||||
// Keep the in-game Town Cryer News gump in sync with the same transition: push
|
||||
// the article when it becomes published news, refresh it silently on an edit,
|
||||
// and pull it when it leaves published-news. Best-effort (never throws), so a
|
||||
// sidecar hiccup never breaks saving a post — same guarantee as the enqueue.
|
||||
await newsGump.syncPost(post, transition)
|
||||
// Opt-in push tickle to news.post subscribers, on the same transition.
|
||||
// Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
|
||||
if (jobId) {
|
||||
Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) =>
|
||||
log.warn('news push failed', { postId: post.id, message: err.message }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
|
||||
@@ -17,6 +17,7 @@ const validate = require('../../../middleware/validate')
|
||||
const mobileRouter = require('./mobile.routes')
|
||||
const ssoRouter = require('./sso.routes')
|
||||
const meRouter = require('./me.routes')
|
||||
const notifRouter = require('./notifications.routes')
|
||||
|
||||
const authRouter = express.Router()
|
||||
|
||||
@@ -34,6 +35,11 @@ authRouter.use(ssoRouter)
|
||||
// free route, so /me falls through to its own handler).
|
||||
authRouter.use('/me', meRouter)
|
||||
|
||||
// Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*.
|
||||
// A second sub-router at /me (Express allows multiple), same requireAuth gate,
|
||||
// keeping the notification surface separate from the account/identity handlers.
|
||||
authRouter.use('/me', notifRouter)
|
||||
|
||||
// Login protection order (cheapest rejection first):
|
||||
// backoffGuard → per-IP exponential lockout on repeated failures
|
||||
// slowLogin → progressive per-request delay within the window
|
||||
|
||||
86
server/src/router/v1/auth/notifications.controller.js
Normal file
86
server/src/router/v1/auth/notifications.controller.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Self-service push-notification management for the logged-in user (any role).
|
||||
// Mounted under /auth/me behind requireAuth, so req.user is the fresh DB row.
|
||||
// Devices (endpoints) and stream subscriptions live here; the fan-out that
|
||||
// actually delivers is utils/pushDispatch. See docs/android/PLAN.md §11.
|
||||
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const { STREAMS } = require('../../../config/notificationStreams')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
const log = require('../../../utils/logger')('notifications')
|
||||
|
||||
// POST /auth/me/devices — register (or refresh) a push endpoint for this user.
|
||||
async function registerDevice(req, res) {
|
||||
const { transport = 'unifiedpush', endpoint, platform } = req.body
|
||||
// SSRF guard: the endpoint is a URL the server will later POST to. Reject
|
||||
// anything that isn't an allowed HTTPS relay origin before storing it.
|
||||
if (!isAllowedEndpoint(endpoint)) {
|
||||
return res.status(400).json({ message: 'Endpoint is not an allowed push URL' })
|
||||
}
|
||||
try {
|
||||
const device = await pushDevices.register({ userId: req.user.id, transport, endpoint, platform })
|
||||
return res.status(201).json(device)
|
||||
} catch (err) {
|
||||
log.error('registerDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/devices — this user's registered devices.
|
||||
async function listDevices(req, res) {
|
||||
try {
|
||||
return res.json(await pushDevices.listForUser(req.user.id))
|
||||
} catch (err) {
|
||||
log.error('listDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /auth/me/devices/:id — unregister a device (must belong to the caller).
|
||||
async function removeDevice(req, res) {
|
||||
try {
|
||||
const ok = await pushDevices.remove(Number(req.params.id), req.user.id)
|
||||
if (!ok) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
log.error('removeDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/streams — the subscribable catalog (static).
|
||||
function getStreams(req, res) {
|
||||
return res.json({ streams: STREAMS })
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.
|
||||
async function getSubscriptions(req, res) {
|
||||
try {
|
||||
return res.json({ streams: await notificationSubs.getForUser(req.user.id) })
|
||||
} catch (err) {
|
||||
log.error('getSubscriptions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /auth/me/notifications/subscriptions — replace the caller's stream set.
|
||||
// Unknown ids are dropped; the stored (cleaned) set is echoed back.
|
||||
async function putSubscriptions(req, res) {
|
||||
try {
|
||||
const streams = await notificationSubs.setForUser(req.user.id, req.body.streams)
|
||||
return res.json({ streams })
|
||||
} catch (err) {
|
||||
log.error('putSubscriptions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerDevice,
|
||||
listDevices,
|
||||
removeDevice,
|
||||
getStreams,
|
||||
getSubscriptions,
|
||||
putSubscriptions,
|
||||
}
|
||||
100
server/src/router/v1/auth/notifications.routes.js
Normal file
100
server/src/router/v1/auth/notifications.routes.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── Push-notification self-service under /auth/me ──────────────────────────
|
||||
//
|
||||
// Device registration + per-user stream subscriptions for the app's opt-in push
|
||||
// (docs/android/PLAN.md §11). Mounted at /me by auth.routes.js alongside
|
||||
// me.routes.js, behind requireAuth ONLY (role-agnostic — every authenticated
|
||||
// role manages its own devices/subscriptions), and noindex. The app calls these
|
||||
// and never touches /admin.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const notif = require('./notifications.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const notifRouter = express.Router()
|
||||
|
||||
notifRouter.use(noindex, requireAuth)
|
||||
|
||||
// ── Devices ────────────────────────────────────────────────────────────────
|
||||
notifRouter.post(
|
||||
'/devices',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Register a push device (endpoint) for the current user'
|
||||
// #swagger.description = 'Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterDeviceRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Device registered', content: { "application/json": { schema: { $ref: "#/components/schemas/PushDevice" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or disallowed endpoint', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('endpoint').isString().trim().isURL({ protocols: ['https'], require_protocol: true }).isLength({ max: 512 }),
|
||||
body('transport').optional().isIn(['unifiedpush', 'fcm']),
|
||||
body('platform').optional({ values: 'falsy' }).isString().isLength({ max: 40 }),
|
||||
validate,
|
||||
notif.registerDevice,
|
||||
)
|
||||
|
||||
notifRouter.get(
|
||||
'/devices',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'List the current user’s registered push devices'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Registered devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/PushDevice" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
notif.listDevices,
|
||||
)
|
||||
|
||||
notifRouter.delete(
|
||||
'/devices/:id',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Unregister a push device'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Device id (must belong to the caller).' }
|
||||
/* #swagger.responses[200] = { description: 'Unregistered', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such device for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
notif.removeDevice,
|
||||
)
|
||||
|
||||
// ── Streams catalog + subscriptions ─────────────────────────────────────────
|
||||
notifRouter.get(
|
||||
'/notifications/streams',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'List subscribable notification streams (catalog)'
|
||||
// #swagger.description = 'The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Stream catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationStreams" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
notif.getStreams,
|
||||
)
|
||||
|
||||
notifRouter.get(
|
||||
'/notifications/subscriptions',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Get the current user’s notification subscriptions'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Subscribed stream ids', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
notif.getSubscriptions,
|
||||
)
|
||||
|
||||
notifRouter.put(
|
||||
'/notifications/subscriptions',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Replace the current user’s notification subscriptions'
|
||||
// #swagger.description = 'Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated subscriptions', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('streams').isArray(),
|
||||
body('streams.*').isString().isLength({ max: 64 }),
|
||||
validate,
|
||||
notif.putSubscriptions,
|
||||
)
|
||||
|
||||
module.exports = notifRouter
|
||||
135
server/src/utils/pushDispatch.js
Normal file
135
server/src/utils/pushDispatch.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// ── Push-notification fan-out (content-free tickles) ───────────────────────
|
||||
//
|
||||
// The transport-agnostic publisher that turns an event into opt-in push
|
||||
// notifications. Two producers call in:
|
||||
// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams
|
||||
// (beside the existing SSE broadcast — same event source, same allowlist).
|
||||
// • the admin create/publish-post path → publish('news.post', …).
|
||||
//
|
||||
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
|
||||
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
|
||||
// endpoint. The app wakes and PULLS the real content over the authenticated,
|
||||
// ownership-checked API. So ntfy is treated as an untrusted relay: a leaked topic
|
||||
// reveals nothing, which is what lets it run with no per-user accounts
|
||||
// (docs/android/PLAN.md §11).
|
||||
//
|
||||
// SECURITY: a device `endpoint` is a client-supplied URL the server makes
|
||||
// server-side POSTs to — a classic SSRF vector. isAllowedEndpoint() gates every
|
||||
// registration AND every publish: HTTPS only, never a private/loopback host, and
|
||||
// (when configured) the origin must be in the shard's ntfy allow-set.
|
||||
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
|
||||
const { mapShardEvent } = require('../config/notificationStreams')
|
||||
const log = require('./logger')('push-dispatch')
|
||||
|
||||
const TIMEOUT_MS = 5000
|
||||
|
||||
// Hosts that must never be POSTed to, even if the allow-set is empty (dev). This
|
||||
// is a coarse literal check (no DNS resolution) — the real protection in prod is
|
||||
// the configured allow-set below, which pins the single ntfy origin.
|
||||
const PRIVATE_HOST =
|
||||
/^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|::1|fc00:|fd00:|172\.(1[6-9]|2\d|3[01])\.)/i
|
||||
|
||||
function toOrigin(u) {
|
||||
try {
|
||||
return new URL(u).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed publish origins, from NTFY_ALLOWED_ORIGINS (comma-separated) or, failing
|
||||
// that, NTFY_BASE_URL's origin. Empty when neither is set (dev fallback).
|
||||
function allowedOrigins() {
|
||||
const raw = process.env.NTFY_ALLOWED_ORIGINS || process.env.NTFY_BASE_URL || ''
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => toOrigin(s.trim()))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Is this endpoint safe to POST to? HTTPS + non-private host + (if an allow-set is
|
||||
// configured) an allowed origin. With no allow-set (dev), any public HTTPS host is
|
||||
// permitted; the private-host check still blocks the obvious SSRF targets.
|
||||
function isAllowedEndpoint(endpoint) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(String(endpoint))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol !== 'https:') return false
|
||||
if (PRIVATE_HOST.test(url.hostname)) return false
|
||||
const allow = allowedOrigins()
|
||||
if (allow.length === 0) return true
|
||||
return allow.includes(url.origin)
|
||||
}
|
||||
|
||||
async function postTickle(endpoint, bodyStr, deps) {
|
||||
const doFetch = deps.fetchImpl || fetch
|
||||
if (!isAllowedEndpoint(endpoint)) {
|
||||
log.warn('skipping push to disallowed endpoint', { endpoint })
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const token = process.env.NTFY_PUBLISH_TOKEN
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
await doFetch(endpoint, { method: 'POST', headers, body: bodyStr, signal: controller.signal })
|
||||
} catch (err) {
|
||||
log.warn('push tickle failed', { message: err.message })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish one content-free tickle. Public (ownerUserId absent) → every device
|
||||
// whose user subscribes to the stream. Personal (ownerUserId set) → only that
|
||||
// user's devices, and only if subscribed. Never throws.
|
||||
async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
const devices = deps.pushDevices || pushDevicesModel
|
||||
let rows
|
||||
try {
|
||||
rows =
|
||||
ownerUserId != null
|
||||
? await devices.endpointsForUserStream(ownerUserId, streamId)
|
||||
: await devices.endpointsForStream(streamId)
|
||||
} catch (err) {
|
||||
log.warn('push endpoint lookup failed', { streamId, message: err.message })
|
||||
return
|
||||
}
|
||||
if (!rows || rows.length === 0) return
|
||||
const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null })
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
|
||||
// owning website user via shardLinks (an unlinked account → nobody to notify).
|
||||
// Never throws — a dead relay must never affect ingest.
|
||||
async function fromShardEvent(event, deps = {}) {
|
||||
const links = deps.shardLinks || shardLinks
|
||||
const targets = mapShardEvent(event, deps.tracker)
|
||||
for (const t of targets) {
|
||||
try {
|
||||
if (t.ownerAccount) {
|
||||
let owner = null
|
||||
try {
|
||||
owner = await links.getByAccount(t.ownerAccount)
|
||||
} catch {
|
||||
owner = null
|
||||
}
|
||||
if (!owner || owner.userId == null) continue
|
||||
await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
|
||||
} else {
|
||||
await publish(t.streamId, { ref: t.ref }, deps)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publish, fromShardEvent, isAllowedEndpoint }
|
||||
@@ -17,6 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const pushDispatch = require('./pushDispatch')
|
||||
const defaultLog = require('./logger')('shard-ingest')
|
||||
|
||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||
@@ -194,6 +195,7 @@ async function ingest(event, deps = {}) {
|
||||
shardLinks: deps.shardLinks || shardLinksModel,
|
||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
|
||||
@@ -226,6 +228,12 @@ async function ingest(event, deps = {}) {
|
||||
} catch (err) {
|
||||
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
|
||||
}
|
||||
// Opt-in push fan-out, off the same event source as the SSE broadcast.
|
||||
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
|
||||
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
|
||||
Promise.resolve(d.pushDispatch(event, { shardLinks: d.shardLinks })).catch((err) =>
|
||||
d.log.warn('push dispatch failed', { kind: event.kind, message: err.message }),
|
||||
)
|
||||
}
|
||||
|
||||
return { logged, stored }
|
||||
|
||||
Reference in New Issue
Block a user