From 094374d4ccb9469b9ec462ec946c2e1707d72388 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Mon, 20 Apr 2026 01:12:07 +0200 Subject: [PATCH 1/5] fix: return HTML for browser Accept headers on root route (#532) --- .../request-handlers/root-request-handler.ts | 31 +++++++++++++++++-- src/routes/index.ts | 5 ++- .../features/nip-11/nip-11.feature | 5 +++ .../features/nip-11/nip-11.feature.ts | 10 ++++++ .../root-request-handler.spec.ts | 22 ++++++++++++- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index d3502fa3..1d8ff4a5 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -1,4 +1,3 @@ -import accepts from 'accepts' import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' import { createSettings } from '../../factories/settings-factory' @@ -8,10 +7,38 @@ import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' import packageJson from '../../../package.json' +export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { + const acceptHeader = request.headers.accept + + if (!acceptHeader) { + return false + } + + return acceptHeader.split(',').some((token) => { + const [mediaType, ...params] = token + .split(';') + .map((value) => value.trim().toLowerCase()) + + if (mediaType !== 'application/nostr+json') { + return false + } + + const quality = params.find((param) => param.startsWith('q=')) + + if (!quality) { + return true + } + + const qValue = Number.parseFloat(quality.slice(2)) + + return !Number.isNaN(qValue) && qValue > 0 + }) +} + export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() - if (accepts(request).type(['application/nostr+json'])) { + if (hasExplicitNostrJsonAcceptHeader(request)) { const { info: { name, description, pubkey: rawPubkey, contact, relay_url }, } = settings diff --git a/src/routes/index.ts b/src/routes/index.ts index c4c2460b..8d29bdbb 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,3 @@ -import accepts from 'accepts' import express from 'express' import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler' @@ -9,12 +8,12 @@ import { getPrivacyRequestHandler } from '../handlers/request-handlers/get-priva import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler' import invoiceRouter from './invoices' import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware' -import { rootRequestHandler } from '../handlers/request-handlers/root-request-handler' +import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handlers/request-handlers/root-request-handler' const router = express.Router() router.use((req, res, next) => { - if (req.method === 'GET' && accepts(req).type(['application/nostr+json'])) { + if (req.method === 'GET' && hasExplicitNostrJsonAcceptHeader(req)) { return rootRequestHandler(req, res, next) } next() diff --git a/test/integration/features/nip-11/nip-11.feature b/test/integration/features/nip-11/nip-11.feature index 6e1bd4c0..bdd8e928 100644 --- a/test/integration/features/nip-11/nip-11.feature +++ b/test/integration/features/nip-11/nip-11.feature @@ -14,6 +14,11 @@ Feature: NIP-11 Then the response Content-Type does not include "application/nostr+json" And the response body is not a relay information document + Scenario: Relay serves HTML for typical browser Accept header + When a browser requests the root path + Then the response Content-Type includes "text/html" + And the response body is not a relay information document + Scenario: Relay information document reports max_filters from settings When a client requests the relay information document Then the limitation object contains a max_filters field diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index 79aa1e13..d2926f55 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -29,6 +29,16 @@ When('a client requests the root path with Accept header {string}', async functi this.parameters.httpResponse = response }) +When('a browser requests the root path', async function(this: World>) { + const response: AxiosResponse = await axios.get(BASE_URL, { + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + }, + validateStatus: () => true, + }) + this.parameters.httpResponse = response +}) + Then('the response status is {int}', function(this: World>, status: number) { expect(this.parameters.httpResponse.status).to.equal(status) }) diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index d10c2e0b..017d515f 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -7,7 +7,10 @@ const { expect } = chai import * as settingsFactory from '../../../../src/factories/settings-factory' import * as templateCache from '../../../../src/utils/template-cache' -import { rootRequestHandler } from '../../../../src/handlers/request-handlers/root-request-handler' +import { + hasExplicitNostrJsonAcceptHeader, + rootRequestHandler, +} from '../../../../src/handlers/request-handlers/root-request-handler' const baseSettings = { info: { @@ -40,6 +43,23 @@ const settingsWithFee = { }, } +describe('hasExplicitNostrJsonAcceptHeader', () => { + it('returns true for explicit application/nostr+json', () => { + expect(hasExplicitNostrJsonAcceptHeader({ headers: { accept: 'application/nostr+json' } } as any)).to.equal(true) + }) + + it('returns false for typical browser Accept header', () => { + const browserAcceptHeader = + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' + + expect(hasExplicitNostrJsonAcceptHeader({ headers: { accept: browserAcceptHeader } } as any)).to.equal(false) + }) + + it('returns false when q=0 for application/nostr+json', () => { + expect(hasExplicitNostrJsonAcceptHeader({ headers: { accept: 'application/nostr+json;q=0' } } as any)).to.equal(false) + }) +}) + describe('rootRequestHandler', () => { let createSettingsStub: sinon.SinonStub let getTemplateStub: sinon.SinonStub From feebb6306685b5c3a506d9caf3cbb03fa6c11af6 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Mon, 20 Apr 2026 01:16:54 +0200 Subject: [PATCH 2/5] fix: missing changeset --- .changeset/red-dancers-ask.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/red-dancers-ask.md diff --git a/.changeset/red-dancers-ask.md b/.changeset/red-dancers-ask.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/red-dancers-ask.md @@ -0,0 +1,2 @@ +--- +--- From a65abe13d2f78cd01ac3cf8eb998759cf17ea1b3 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Mon, 20 Apr 2026 01:23:32 +0200 Subject: [PATCH 3/5] fix: scope NIP-11 accept routing to root and handle array Accept headers (#532) --- src/handlers/request-handlers/root-request-handler.ts | 4 +++- src/routes/index.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 1d8ff4a5..5aa2b265 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -14,7 +14,9 @@ export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { return false } - return acceptHeader.split(',').some((token) => { + const acceptHeaderValue = Array.isArray(acceptHeader) ? acceptHeader.join(',') : acceptHeader + + return acceptHeaderValue.split(',').some((token) => { const [mediaType, ...params] = token .split(';') .map((value) => value.trim().toLowerCase()) diff --git a/src/routes/index.ts b/src/routes/index.ts index 8d29bdbb..28f94336 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -13,7 +13,7 @@ import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handler const router = express.Router() router.use((req, res, next) => { - if (req.method === 'GET' && hasExplicitNostrJsonAcceptHeader(req)) { + if (req.method === 'GET' && req.path === '/' && hasExplicitNostrJsonAcceptHeader(req)) { return rootRequestHandler(req, res, next) } next() From 331c8d4ec8d0cbbf4a5765eb64c1f9b6b94d23be Mon Sep 17 00:00:00 2001 From: justxd22 Date: Sun, 26 Apr 2026 22:55:41 +0300 Subject: [PATCH 4/5] fix: respect public subpath in browser HTML --- resources/get-invoice.html | 4 +- resources/index.html | 6 +- resources/invoices.html | 7 +- resources/post-invoice.html | 5 +- resources/privacy.html | 4 +- .../invoices/get-invoice-controller.ts | 4 +- .../invoices/post-invoice-controller.ts | 5 +- .../get-privacy-request-handler.ts | 7 +- .../get-terms-request-handler.ts | 7 +- .../request-handlers/root-request-handler.ts | 5 +- src/utils/http.ts | 56 +++++++++++ .../invoices/get-invoice-controller.spec.ts | 18 +++- .../invoices/post-invoice-controller.spec.ts | 25 ++++- .../get-privacy-request-handler.spec.ts | 9 ++ .../root-request-handler.spec.ts | 50 ++++++++++ test/unit/utils/http.spec.ts | 94 ++++++++++++++++++- 16 files changed, 282 insertions(+), 24 deletions(-) diff --git a/resources/get-invoice.html b/resources/get-invoice.html index 7e72a439..9682a0c7 100644 --- a/resources/get-invoice.html +++ b/resources/get-invoice.html @@ -9,7 +9,7 @@
-
+

{{name}}

@@ -46,7 +46,7 @@

{{name}}

diff --git a/resources/index.html b/resources/index.html index c982f9e0..c0fa55df 100644 --- a/resources/index.html +++ b/resources/index.html @@ -46,7 +46,7 @@
Admission Required
This relay requires a one-time admission fee of {{amount}} sats to publish events. Reading events is free.

- Pay Admission Fee + Pay Admission Fee
@@ -62,9 +62,9 @@
Open Relay
diff --git a/resources/invoices.html b/resources/invoices.html index 3df95f0c..0742fed4 100644 --- a/resources/invoices.html +++ b/resources/invoices.html @@ -14,7 +14,7 @@
- +

{{name}}

@@ -106,6 +106,7 @@

Invoice expired!

var reference = "{{reference}}" var relayUrl = "{{relay_url}}" var relayPubkey = "{{relay_pubkey}}" + var pathPrefix = {{path_prefix_json}}; var invoice = "{{invoice}}"; var pubkey = "{{pubkey}}" var expiresAt = "{{expires_at}}" @@ -124,7 +125,7 @@

Invoice expired!

} async function getInvoiceStatus() { - fetch(`/invoices/${reference}/status`).then(async (response) => { + fetch(`${pathPrefix}/invoices/${reference}/status`).then(async (response) => { const data = await response.json() console.log('data', data) const { status } = data; @@ -269,4 +270,4 @@

Invoice expired!

document.getElementById('sendPaymentBtn').addEventListener('click', sendPayment) - \ No newline at end of file + diff --git a/resources/post-invoice.html b/resources/post-invoice.html index 446efdb6..119d0750 100644 --- a/resources/post-invoice.html +++ b/resources/post-invoice.html @@ -14,7 +14,7 @@
- +

{{name}}

@@ -106,6 +106,7 @@

Invoice expired!

var reference = {{reference_json}} var relayUrl = {{relay_url_json}} var relayPubkey = {{relay_pubkey_json}} + var pathPrefix = {{path_prefix_json}} var invoice = {{invoice_json}} var pubkey = {{pubkey_json}} var expiresAt = {{expires_at_json}} @@ -124,7 +125,7 @@

Invoice expired!

} async function getInvoiceStatus() { - fetch(`/invoices/${reference}/status`).then(async (response) => { + fetch(`${pathPrefix}/invoices/${reference}/status`).then(async (response) => { if (!response.ok) { throw new Error(`unexpected status ${response.status}`) } diff --git a/resources/privacy.html b/resources/privacy.html index af36159d..eb8f7785 100644 --- a/resources/privacy.html +++ b/resources/privacy.html @@ -61,9 +61,9 @@
Changes to this policy

diff --git a/src/controllers/invoices/get-invoice-controller.ts b/src/controllers/invoices/get-invoice-controller.ts index 621532f5..510d9b92 100644 --- a/src/controllers/invoices/get-invoice-controller.ts +++ b/src/controllers/invoices/get-invoice-controller.ts @@ -8,9 +8,10 @@ import { FeeSchedule } from '../../@types/settings' import { IController } from '../../@types/controllers' import { getTemplate } from '../../utils/template-cache' +import { getPublicPathPrefix } from '../../utils/http' export class GetInvoiceController implements IController { - public async handleRequest(_req: Request, res: Response): Promise { + public async handleRequest(req: Request, res: Response): Promise { const settings = createSettings() if ( @@ -21,6 +22,7 @@ export class GetInvoiceController implements IController { const feeSchedule = path(['payments', 'feeSchedules', 'admission', '0'], settings) const page = getTemplate('./resources/get-invoice.html') .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, settings))) .replaceAll('{{processor_json}}', safeJsonForScript(settings.payments.processor)) .replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString()) .replaceAll('{{nonce}}', res.locals.nonce) diff --git a/src/controllers/invoices/post-invoice-controller.ts b/src/controllers/invoices/post-invoice-controller.ts index e8bee3fe..f35443dc 100644 --- a/src/controllers/invoices/post-invoice-controller.ts +++ b/src/controllers/invoices/post-invoice-controller.ts @@ -14,7 +14,7 @@ import { createLogger } from '../../factories/logger-factory' import { escapeHtml, safeJsonForScript } from '../../utils/html' import { fromBech32, toBech32 } from '../../utils/transform' import { getPublicKey, getRelayPrivateKey } from '../../utils/event' -import { getRemoteAddress } from '../../utils/http' +import { getPublicPathPrefix, getRemoteAddress } from '../../utils/http' import { getTemplate } from '../../utils/template-cache' const logger = createLogger('post-invoice-controller') @@ -125,6 +125,7 @@ export class PostInvoiceController implements IController { const relayPubkey = getPublicKey(relayPrivkey) const expiresAt = invoice.expiresAt?.toISOString() ?? '' + const pathPrefix = getPublicPathPrefix(request, currentSettings) const pageContent = getTemplate('./resources/post-invoice.html') const body = pageContent @@ -133,6 +134,7 @@ export class PostInvoiceController implements IController { .replaceAll('{{relay_url_html}}', escapeHtml(relayUrl)) .replaceAll('{{invoice_html}}', escapeHtml(invoice.bolt11)) .replaceAll('{{pubkey_html}}', escapeHtml(pubkey)) + .replaceAll('{{path_prefix}}', escapeHtml(pathPrefix)) .replaceAll('{{amount}}', (amount / 1000n).toString()) // JS contexts — safeJsonForScript serializes and escapes < to prevent injection .replaceAll('{{reference_json}}', safeJsonForScript(invoice.id)) @@ -141,6 +143,7 @@ export class PostInvoiceController implements IController { .replaceAll('{{invoice_json}}', safeJsonForScript(invoice.bolt11)) .replaceAll('{{pubkey_json}}', safeJsonForScript(pubkey)) .replaceAll('{{expires_at_json}}', safeJsonForScript(expiresAt)) + .replaceAll('{{path_prefix_json}}', safeJsonForScript(pathPrefix)) .replaceAll('{{processor_json}}', safeJsonForScript(currentSettings.payments.processor)) // nonce is crypto-random base64 — safe in both attribute and script contexts .replaceAll('{{nonce}}', response.locals.nonce) diff --git a/src/handlers/request-handlers/get-privacy-request-handler.ts b/src/handlers/request-handlers/get-privacy-request-handler.ts index eaf931d1..fbe0379a 100644 --- a/src/handlers/request-handlers/get-privacy-request-handler.ts +++ b/src/handlers/request-handlers/get-privacy-request-handler.ts @@ -3,18 +3,21 @@ import { NextFunction, Request, Response } from 'express' import { createSettings as settings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' import { getTemplate } from '../../utils/template-cache' -export const getPrivacyRequestHandler = (_req: Request, res: Response, next: NextFunction) => { +export const getPrivacyRequestHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() const { info: { name }, - } = settings() + } = currentSettings let page: string try { page = getTemplate('./resources/privacy.html') .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) .replaceAll('{{nonce}}', res.locals.nonce) } catch (err) { next(err) diff --git a/src/handlers/request-handlers/get-terms-request-handler.ts b/src/handlers/request-handlers/get-terms-request-handler.ts index 66747cc2..32cc755b 100644 --- a/src/handlers/request-handlers/get-terms-request-handler.ts +++ b/src/handlers/request-handlers/get-terms-request-handler.ts @@ -1,17 +1,20 @@ import { NextFunction, Request, Response } from 'express' import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' import { getTemplate } from '../../utils/template-cache' import { createSettings as settings } from '../../factories/settings-factory' -export const getTermsRequestHandler = (_req: Request, res: Response, next: NextFunction) => { +export const getTermsRequestHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() const { info: { name }, - } = settings() + } = currentSettings let page: string try { page = getTemplate('./resources/terms.html') .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) .replaceAll('{{nonce}}', res.locals.nonce) } catch (err) { next(err) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 5aa2b265..198b0f3c 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -5,6 +5,7 @@ import { escapeHtml } from '../../utils/html' import { FeeSchedule } from '../../@types/settings' import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' +import { getPublicPathPrefix, joinPathPrefix } from '../../utils/http' import packageJson from '../../../package.json' export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { @@ -39,6 +40,7 @@ export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() + const pathPrefix = getPublicPathPrefix(request, settings) if (hasExplicitNostrJsonAcceptHeader(request)) { const { @@ -47,7 +49,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N const paymentsUrl = new URL(relay_url) paymentsUrl.protocol = paymentsUrl.protocol === 'wss:' ? 'https:' : 'http:' - paymentsUrl.pathname = '/invoices' + paymentsUrl.pathname = joinPathPrefix(pathPrefix, '/invoices') const content = settings.limits?.event?.content @@ -116,6 +118,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N .replaceAll('{{description}}', escapeHtml(settings.info.description ?? '')) .replaceAll('{{relay_url}}', escapeHtml(settings.info.relay_url)) .replaceAll('{{amount}}', amount) + .replaceAll('{{path_prefix}}', escapeHtml(pathPrefix)) .replaceAll('{{payments_section_class}}', admissionFeeEnabled ? '' : 'd-none') .replaceAll('{{no_payments_section_class}}', admissionFeeEnabled ? 'd-none' : '') .replaceAll('{{nonce}}', response.locals.nonce) diff --git a/src/utils/http.ts b/src/utils/http.ts index 0903330a..5d10fe9e 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -56,3 +56,59 @@ export const getRemoteAddress = (request: IncomingMessage, settings: Settings): return (result as string).split(',')[0].trim() } + +const normalizePathPrefix = (pathPrefix: string | undefined): string => { + if (typeof pathPrefix !== 'string') { + return '' + } + + const prefix = pathPrefix.split(',')[0].trim() + + if (!prefix.startsWith('/') || prefix.startsWith('//')) { + return '' + } + + try { + const { pathname } = new URL(prefix, 'http://nostream.local') + const normalized = pathname.replace(/\/+$/, '') + + return normalized === '/' ? '' : normalized + } catch { + return '' + } +} + +const getRelayUrlPathPrefix = (relayUrl: string | undefined): string => { + if (typeof relayUrl !== 'string') { + return '' + } + + try { + return normalizePathPrefix(new URL(relayUrl).pathname) + } catch { + return '' + } +} + +const getTrustedForwardedPathPrefix = (request: IncomingMessage, settings: Settings): string => { + const socketAddress = request.socket?.remoteAddress + if (typeof socketAddress !== 'string' || !isTrustedProxy(socketAddress, settings)) { + return '' + } + + const rawHeader = request.headers?.['x-forwarded-prefix'] + const rawPrefix = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader + + return normalizePathPrefix(rawPrefix) +} + +export const getPublicPathPrefix = (request: IncomingMessage, settings: Settings): string => { + return getTrustedForwardedPathPrefix(request, settings) || getRelayUrlPathPrefix(settings.info?.relay_url) +} + +export const joinPathPrefix = (prefix: string, path: string): string => { + const normalizedPrefix = prefix.replace(/\/+$/, '') + const normalizedPath = path.startsWith('/') ? path : `/${path}` + + return `${normalizedPrefix}${normalizedPath}` +} diff --git a/test/unit/controllers/invoices/get-invoice-controller.spec.ts b/test/unit/controllers/invoices/get-invoice-controller.spec.ts index 778e9325..9b76c06e 100644 --- a/test/unit/controllers/invoices/get-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/get-invoice-controller.spec.ts @@ -21,7 +21,7 @@ const disabledPaymentsSettings = { } const enabledPaymentsSettings = { - info: { name: 'Test Relay' }, + info: { name: 'Test Relay', relay_url: 'wss://relay.example.com' }, payments: { enabled: true, feeSchedules: { @@ -29,6 +29,7 @@ const enabledPaymentsSettings = { }, processor: 'lnbits', }, + network: {}, } describe('GetInvoiceController', () => { @@ -97,7 +98,7 @@ describe('GetInvoiceController', () => { describe('when payments and admission fee are enabled', () => { beforeEach(() => { createSettingsStub.returns(enabledPaymentsSettings) - getTemplateStub.returns('{{name}}|{{processor_json}}|{{amount}}|{{nonce}}') + getTemplateStub.returns('{{name}}|{{path_prefix}}|{{processor_json}}|{{amount}}|{{nonce}}') }) it('loads the get-invoice template', async () => { @@ -118,6 +119,7 @@ describe('GetInvoiceController', () => { const sent = res.send.firstCall.args[0] as string expect(sent).to.not.include('{{name}}') + expect(sent).to.not.include('{{path_prefix}}') expect(sent).to.not.include('{{processor_json}}') expect(sent).to.not.include('{{amount}}') expect(sent).to.not.include('{{nonce}}') @@ -164,5 +166,17 @@ describe('GetInvoiceController', () => { expect(res.send.firstCall.args[0]).to.equal('invoice-nonce') }) + + it('injects relay_url path prefix into form actions', async () => { + getTemplateStub.returns('{{path_prefix}}/invoices') + createSettingsStub.returns({ + ...enabledPaymentsSettings, + info: { ...enabledPaymentsSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + + await controller.handleRequest({ headers: {} } as any, res) + + expect(res.send.firstCall.args[0]).to.equal('/nostream/invoices') + }) }) }) diff --git a/test/unit/controllers/invoices/post-invoice-controller.spec.ts b/test/unit/controllers/invoices/post-invoice-controller.spec.ts index 1475087e..9607c0be 100644 --- a/test/unit/controllers/invoices/post-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/post-invoice-controller.spec.ts @@ -307,9 +307,9 @@ describe('PostInvoiceController', () => { it('leaves no unreplaced template variables in the output', async () => { getTemplateStub.returns( - '{{name}}{{relay_url_html}}{{invoice_html}}{{pubkey_html}}{{amount}}' + + '{{name}}{{relay_url_html}}{{invoice_html}}{{pubkey_html}}{{path_prefix}}{{amount}}' + '{{reference_json}}{{relay_url_json}}{{relay_pubkey_json}}' + - '{{invoice_json}}{{pubkey_json}}{{expires_at_json}}{{processor_json}}{{nonce}}', + '{{invoice_json}}{{pubkey_json}}{{expires_at_json}}{{path_prefix_json}}{{processor_json}}{{nonce}}', ) const controller = makeController() const res = makeRes() @@ -319,5 +319,26 @@ describe('PostInvoiceController', () => { const sent = res.send.firstCall.args[0] as string expect(sent).to.not.match(/\{\{[^}]+\}\}/) }) + + it('injects relay_url path prefix into form actions and status polling', async () => { + getTemplateStub.returns('{{path_prefix}}/invoices|{{path_prefix_json}}') + const settings = () => ({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + const controller = makeController({ settings }) + const res = makeRes() + + await controller.handleRequest( + { + body: validBody, + headers: {}, + } as any, + res, + ) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.equal('/nostream/invoices|"/nostream"') + }) }) }) diff --git a/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts b/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts index 840e0f55..9718ea45 100644 --- a/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts @@ -71,6 +71,15 @@ describe('getPrivacyRequestHandler', () => { expect(res.send.firstCall.args[0]).to.equal('privacy-nonce') }) + it('injects relay_url path prefix into links', () => { + createSettingsStub.returns({ info: { name: 'Test Relay', relay_url: 'wss://relay.example.com/nostream' } }) + getTemplateStub.returns('{{path_prefix}}/terms') + + getPrivacyRequestHandler({ headers: {} } as any, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/nostream/terms') + }) + it('calls next with error when template read fails', () => { const err = new Error('template missing') getTemplateStub.throws(err) diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 017d515f..12b836ba 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -115,6 +115,18 @@ describe('rootRequestHandler', () => { expect(getTemplateStub).to.not.have.been.called }) + + it('includes relay_url path prefix in payments_url', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.payments_url).to.equal('https://relay.example.com/nostream/invoices') + }) }) describe('when serving HTML', () => { @@ -182,6 +194,44 @@ describe('rootRequestHandler', () => { expect(res.send.firstCall.args[0]).to.equal('test-nonce') }) + it('injects relay_url path prefix into links', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + getTemplateStub.returns('{{path_prefix}}/invoices|{{path_prefix}}/terms') + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/nostream/invoices|/nostream/terms') + }) + + it('uses trusted forwarded path prefix over relay_url path', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + network: { ...baseSettings.network, trustedProxies: ['127.0.0.1'] }, + }) + getTemplateStub.returns('{{path_prefix}}/invoices') + req.headers['x-forwarded-prefix'] = '/proxy' + req.socket = { remoteAddress: '127.0.0.1' } + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/proxy/invoices') + }) + + it('ignores forwarded path prefix when proxy is not trusted', () => { + createSettingsStub.returns(baseSettings) + getTemplateStub.returns('{{path_prefix}}/invoices') + req.headers['x-forwarded-prefix'] = '/nostream' + req.socket = { remoteAddress: '127.0.0.1' } + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/invoices') + }) + it('shows amount in sats when admission fee is enabled', () => { createSettingsStub.returns(settingsWithFee) getTemplateStub.returns('{{amount}}') diff --git a/test/unit/utils/http.spec.ts b/test/unit/utils/http.spec.ts index eddfa29a..5f0ad205 100644 --- a/test/unit/utils/http.spec.ts +++ b/test/unit/utils/http.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import { IncomingMessage } from 'http' -import { getRemoteAddress } from '../../../src/utils/http' +import { getPublicPathPrefix, getRemoteAddress, joinPathPrefix } from '../../../src/utils/http' describe('getRemoteAddress', () => { const header = 'x-forwarded-for' @@ -86,3 +86,95 @@ describe('getRemoteAddress', () => { ).to.equal(address) }) }) + +describe('getPublicPathPrefix', () => { + it('returns the relay_url path prefix by default', () => { + expect( + getPublicPathPrefix({ headers: {}, socket: { remoteAddress: 'client' } } as any, { + info: { relay_url: 'wss://relay.example.com/nostream/' }, + network: {}, + } as any), + ).to.equal('/nostream') + }) + + it('uses trusted x-forwarded-prefix over relay_url', () => { + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '/relay, /other' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any, + ), + ).to.equal('/relay') + }) + + it('ignores untrusted x-forwarded-prefix', () => { + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '/evil' }, + socket: { remoteAddress: 'client' }, + } as any, + { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any, + ), + ).to.equal('/nostream') + }) + + it('ignores x-forwarded-prefix when trustedProxies is unset', () => { + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '/nostream' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + { + info: { relay_url: 'wss://relay.example.com' }, + network: {}, + } as any, + ), + ).to.equal('') + }) + + it('rejects absolute or protocol-relative trusted prefixes', () => { + const settings = { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any + + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': 'https://example.com/other' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + settings, + ), + ).to.equal('/nostream') + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '//example.com/other' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + settings, + ), + ).to.equal('/nostream') + }) +}) + +describe('joinPathPrefix', () => { + it('joins an empty prefix with an absolute path', () => { + expect(joinPathPrefix('', '/invoices')).to.equal('/invoices') + }) + + it('joins a forwarded prefix with an absolute path', () => { + expect(joinPathPrefix('/nostream', '/invoices')).to.equal('/nostream/invoices') + }) +}) From a3224f8f96d1fc66a2e1ad72730ff683104f7be3 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Mon, 27 Apr 2026 00:07:16 +0300 Subject: [PATCH 5/5] fix: drop unused depends --- .changeset/dark-places-tickle.md | 5 +++++ .changeset/red-dancers-ask.md | 2 -- package.json | 3 +-- pnpm-lock.yaml | 3 --- 4 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 .changeset/dark-places-tickle.md delete mode 100644 .changeset/red-dancers-ask.md diff --git a/.changeset/dark-places-tickle.md b/.changeset/dark-places-tickle.md new file mode 100644 index 00000000..6ed7f8f4 --- /dev/null +++ b/.changeset/dark-places-tickle.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Fix root HTML negotiation and subpath-aware template links behind trusted proxies. diff --git a/.changeset/red-dancers-ask.md b/.changeset/red-dancers-ask.md deleted file mode 100644 index a845151c..00000000 --- a/.changeset/red-dancers-ask.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/package.json b/package.json index 675394da..4286a409 100644 --- a/package.json +++ b/package.json @@ -152,10 +152,9 @@ "node": ">=24.14.1" }, "dependencies": { - "@getalby/sdk": "^5.0.0", "@clack/prompts": "^1.2.0", + "@getalby/sdk": "^5.0.0", "@noble/secp256k1": "1.7.1", - "accepts": "^1.3.8", "axios": "^1.15.0", "cac": "^7.0.0", "colorette": "^2.0.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 153bcb60..042562e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@noble/secp256k1': specifier: 1.7.1 version: 1.7.1 - accepts: - specifier: ^1.3.8 - version: 1.3.8 axios: specifier: ^1.15.0 version: 1.15.1