Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dark-places-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

Fix root HTML negotiation and subpath-aware template links behind trusted proxies.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions resources/get-invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</head>
<body lang="en">
<main class="container">
<form method="post" action="/invoices">
<form method="post" action="{{path_prefix}}/invoices">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
Expand Down Expand Up @@ -46,7 +46,7 @@ <h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tosAccepted" name="tosAccepted" value="yes" required>
<label class="form-check-label" for="tosAccepted">
I have read and agree to the <a href="/terms" class="card-link" target="_blank" rel="noopener noreferrer">Terms of Service</a>
I have read and agree to the <a href="{{path_prefix}}/terms" class="card-link" target="_blank" rel="noopener noreferrer">Terms of Service</a>
</label>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions resources/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ <h5 class="card-title">Admission Required</h5>
This relay requires a one-time admission fee of <strong>{{amount}} sats</strong>
to publish events. Reading events is free.
</p>
<a href="/invoices" class="btn btn-warning">Pay Admission Fee</a>
<a href="{{path_prefix}}/invoices" class="btn btn-warning">Pay Admission Fee</a>
</div>
</div>

Expand All @@ -62,9 +62,9 @@ <h5 class="card-title">Open Relay</h5>

<!-- Legal links -->
<div class="d-flex justify-content-center gap-3 mt-2 mb-5">
<a href="/terms" class="text-muted small">Terms of Service</a>
<a href="{{path_prefix}}/terms" class="text-muted small">Terms of Service</a>
<span class="text-muted small">·</span>
<a href="/privacy" class="text-muted small">Privacy Policy</a>
<a href="{{path_prefix}}/privacy" class="text-muted small">Privacy Policy</a>
</div>

</div>
Expand Down
7 changes: 4 additions & 3 deletions resources/invoices.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</head>
<body lang="en">
<main class="container">
<form method="post" action="/invoices">
<form method="post" action="{{path_prefix}}/invoices">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
Expand Down Expand Up @@ -106,6 +106,7 @@ <h2 class="text-danger">Invoice expired!</h2>
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}}"
Expand All @@ -124,7 +125,7 @@ <h2 class="text-danger">Invoice expired!</h2>
}

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;
Expand Down Expand Up @@ -269,4 +270,4 @@ <h2 class="text-danger">Invoice expired!</h2>
document.getElementById('sendPaymentBtn').addEventListener('click', sendPayment)
</script>
</body>
</html>
</html>
5 changes: 3 additions & 2 deletions resources/post-invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</head>
<body lang="en">
<main class="container">
<form method="post" action="/invoices">
<form method="post" action="{{path_prefix}}/invoices">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
Expand Down Expand Up @@ -106,6 +106,7 @@ <h2 class="text-danger">Invoice expired!</h2>
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}}
Expand All @@ -124,7 +125,7 @@ <h2 class="text-danger">Invoice expired!</h2>
}

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}`)
}
Expand Down
4 changes: 2 additions & 2 deletions resources/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ <h5>Changes to this policy</h5>
</p>

<div class="mt-4">
<a href="/" class="text-muted small">← Back to home</a>
<a href="{{path_prefix}}/" class="text-muted small">← Back to home</a>
<span class="text-muted small mx-2">·</span>
<a href="/terms" class="text-muted small">Terms of Service</a>
<a href="{{path_prefix}}/terms" class="text-muted small">Terms of Service</a>
</div>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/controllers/invoices/get-invoice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
public async handleRequest(req: Request, res: Response): Promise<void> {
const settings = createSettings()

if (
Expand All @@ -21,6 +22,7 @@ export class GetInvoiceController implements IController {
const feeSchedule = path<FeeSchedule>(['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)
Expand Down
5 changes: 4 additions & 1 deletion src/controllers/invoices/post-invoice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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 </script> injection
.replaceAll('{{reference_json}}', safeJsonForScript(invoice.id))
Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions src/handlers/request-handlers/get-privacy-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions src/handlers/request-handlers/get-terms-request-handler.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
38 changes: 35 additions & 3 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,19 +6,51 @@ import { FeeSchedule } from '../../@types/settings'
import { DEFAULT_FILTER_LIMIT } from '../../constants/base'
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 => {
const acceptHeader = request.headers.accept

if (!acceptHeader) {
return false
}

const acceptHeaderValue = Array.isArray(acceptHeader) ? acceptHeader.join(',') : acceptHeader

return acceptHeaderValue.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()
const pathPrefix = getPublicPathPrefix(request, settings)

if (accepts(request).type(['application/nostr+json'])) {
if (hasExplicitNostrJsonAcceptHeader(request)) {
const {
info: { name, description, banner, icon, pubkey: rawPubkey, self: rawSelf, contact, relay_url, terms_of_service },
} = settings

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
const eventLimits = settings.limits?.event
Expand Down Expand Up @@ -112,6 +143,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)
Expand Down
5 changes: 2 additions & 3 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import accepts from 'accepts'
import express, { Router } from 'express'

import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler'
Expand All @@ -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: Router = express.Router()

router.use((req, res, next) => {
if (req.method === 'GET' && accepts(req).type(['application/nostr+json'])) {
if (req.method === 'GET' && req.path === '/' && hasExplicitNostrJsonAcceptHeader(req)) {
return rootRequestHandler(req, res, next)
}
next()
Expand Down
56 changes: 56 additions & 0 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
5 changes: 5 additions & 0 deletions test/integration/features/nip-11/nip-11.feature
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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
Expand Down
Loading
Loading