feat(settings): surface push.ntfyUrl in /public/settings for the app #79
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user