diff --git a/server/.env.example b/server/.env.example index bb7093f..cde5572 100644 --- a/server/.env.example +++ b/server/.env.example @@ -101,12 +101,20 @@ TOWNCRIER_DURATION_SEC=3600 # Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted # ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs # content-free tickles to each device's endpoint, so no publish token is required. -# NTFY_BASE_URL Public relay URL; also the backend's SSRF allow-set — a -# device may only register an endpoint on this origin. -# NTFY_ALLOWED_ORIGINS Optional comma-separated extra allowed origins. +# NTFY_BASE_URL Internal relay URL the publisher POSTs to; also part of the +# backend's SSRF allow-set — a device may only register an +# endpoint on an allowed origin. +# NTFY_PUBLIC_URL Client-facing relay URL surfaced to the app via +# /public/settings.push.ntfyUrl (the app registers its topic +# endpoint here). Defaults to the first NTFY_ALLOWED_ORIGINS +# entry; set when the public URL differs from NTFY_BASE_URL. +# NTFY_ALLOWED_ORIGINS Optional comma-separated allowed origins (the app's endpoint +# must sit on one). Also the default source for NTFY_PUBLIC_URL. # NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default). # Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint -# (private/loopback hosts are always rejected). +# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL / +# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard. # NTFY_BASE_URL=https://ntfy.example.com -# NTFY_ALLOWED_ORIGINS= +# NTFY_PUBLIC_URL=https://ntfy.example.com +# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com # NTFY_PUBLISH_TOKEN= diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index f93de70..1b9a605 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -105,9 +105,28 @@ async function getPublic() { hero: brand.hero, favicon: brand.favicon, } + // Push-notification relay (M7). The client-facing ntfy base URL the app's + // embedded distributor registers its device topic against; null when push is + // not configured for this shard, in which case the app simply shows push as + // unavailable. The publisher's own NTFY_BASE_URL may be an internal + // compose-network address, so a distinct NTFY_PUBLIC_URL is preferred; failing + // that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit + // on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only. + out.push = { ntfyUrl: publicNtfyUrl() } return out } +// The client-facing ntfy base URL (no trailing slash), or null when unset. +function publicNtfyUrl() { + const explicit = (process.env.NTFY_PUBLIC_URL || '').trim() + if (explicit) return explicit.replace(/\/+$/, '') + const firstOrigin = (process.env.NTFY_ALLOWED_ORIGINS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean)[0] + return firstOrigin ? firstOrigin.replace(/\/+$/, '') : null +} + module.exports = { get, set, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index dc63df9..19b90e4 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -14952,6 +14952,41 @@ }, "brand": { "$ref": "#/components/schemas/Brand" + }, + "push": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard." + }, + "properties": { + "type": "object", + "properties": { + "ntfyUrl": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "https://ntfy.example.com" + } + } + } + } + } + } } } }, diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 879d0ad..c31ec07 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -680,6 +680,14 @@ const doc = { }, gameAccountSignup: { type: 'boolean', example: false }, brand: { $ref: '#/components/schemas/Brand' }, + push: { + type: 'object', + description: + 'Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard.', + properties: { + ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' }, + }, + }, }, additionalProperties: true, }, diff --git a/server/test/publicBrand.test.js b/server/test/publicBrand.test.js index e4deac9..1e36426 100644 --- a/server/test/publicBrand.test.js +++ b/server/test/publicBrand.test.js @@ -55,3 +55,51 @@ test('admin site_title / contact_email override the brand defaults', async () => assert.equal(pub.brand.contactEmail, 'hi@shard.tld') // contact_email overrides assert.equal(pub.brand.accent, brand.accent) // colors still from config }) + +// The push relay block the app's embedded distributor discovers its ntfy base +// URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins, +// else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced. +test('push.ntfyUrl is null when no ntfy env is configured', async () => { + const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL } + delete process.env.NTFY_PUBLIC_URL + delete process.env.NTFY_ALLOWED_ORIGINS + process.env.NTFY_BASE_URL = 'http://ntfy:80' // internal-only, must NOT leak + try { + const pub = await settings.getPublic() + assert.ok(pub.push, 'push block present') + assert.equal(pub.push.ntfyUrl, null) + } finally { + restoreNtfyEnv(saved) + } +}) + +test('push.ntfyUrl prefers NTFY_PUBLIC_URL and trims a trailing slash', async () => { + const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL } + process.env.NTFY_PUBLIC_URL = 'https://ntfy.shard.tld/' + process.env.NTFY_ALLOWED_ORIGINS = 'https://other.tld' + try { + const pub = await settings.getPublic() + assert.equal(pub.push.ntfyUrl, 'https://ntfy.shard.tld') + } finally { + restoreNtfyEnv(saved) + } +}) + +test('push.ntfyUrl falls back to the first NTFY_ALLOWED_ORIGINS entry', async () => { + const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL } + delete process.env.NTFY_PUBLIC_URL + process.env.NTFY_ALLOWED_ORIGINS = 'https://ntfy.shard.tld, https://second.tld' + try { + const pub = await settings.getPublic() + assert.equal(pub.push.ntfyUrl, 'https://ntfy.shard.tld') + } finally { + restoreNtfyEnv(saved) + } +}) + +function restoreNtfyEnv(saved) { + for (const [name, val] of [['NTFY_PUBLIC_URL', saved.pub], ['NTFY_ALLOWED_ORIGINS', saved.allow], ['NTFY_BASE_URL', saved.base]]) { + if (val === undefined) delete process.env[name] + else process.env[name] = val + } +}