Skip to content
Merged
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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
- Set `payments.enabled` to `true`
- Set `payments.feeSchedules.admission.enabled` to `true`
- Set `limits.event.pubkey.minBalance` to the minimum balance in msats required to accept events (i.e. `1000000` to require a balance of `1000` sats)
- Choose one of the following payment processors: `zebedee`, `nodeless`, `lnbits`, `lnurl`
- Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`

2. [ZEBEDEE](https://zebedee.io)
- Complete the step "Before you begin"
Expand All @@ -113,9 +113,9 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
- Read the in-depth guide for more information: [Set Up a Paid Nostr Relay with ZEBEDEE API](https://docs.zebedee.io/docs/guides/nostr-relay)

3. [Nodeless.io](https://nodeless.io)
3. [Nodeless](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731)
- Complete the step "Before you begin"
- Sign up for a new account at https://nodeless.io, create a new store and take note of the store ID
- [Sign up](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) for a new account, create a new store and take note of the store ID
- Go to Profile > API Tokens and generate a new key and take note of it
- Create a store webhook with your Nodeless callback URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/nodeless`) and make sure to enable all of the events. Grab the generated store webhook secret
- Set `NODELESS_API_KEY` and `NODELESS_WEBHOOK_SECRET` environment variables with generated API key and webhook secret, respectively
Expand All @@ -130,9 +130,24 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
- Set `paymentsProcessors.nodeless.storeId` to your store ID
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)

4. [LNBITS](https://lnbits.com/)
4. [OpenNode](https://www.opennode.com/)
- Complete the step "Before you begin"
- Sign up for a new account and get verified
- Go to Developers > Integrations and setup two-factor authentication
- Create a new API Key with Invoices permission
- Set `OPENNODE_API_KEY` environment variable on your `.env` file

```
OPENNODE_API_KEY={YOUR_OPENNODE_API_KEY}
```

- On your `.nostr/settings.yaml` file make the following changes:
- Set `payments.processor` to `opennode`
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)

5. [LNBITS](https://lnbits.com/)
- Complete the step "Before you begin"
- Create a new wallet on you public LNbits instance
- Create a new wallet on you public LNbits instance
- [Demo](https://legend.lnbits.com/) server must not be used for production
- Your instance must be accessible from the internet and have a valid SSL/TLS certificate
- Get wallet Invoice/read key (in Api docs section of your wallet)
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@ services:
TOR_CONTROL_PORT: 9051
TOR_PASSWORD: nostr_ts_relay
HIDDEN_SERVICE_PORT: 80
# Payments Processors
# Zebedee
ZEBEDEE_API_KEY: ${ZEBEDEE_API_KEY}
# Nodeless.io
NODELESS_API_KEY: ${NODELESS_API_KEY}
NODELESS_WEBHOOK_SECRET: ${NODELESS_WEBHOOK_SECRET}
# OpenNode
OPENNODE_API_KEY: ${OPENNODE_API_KEY}
# Lnbits
LNBITS_API_KEY: ${LNBITS_API_KEY}
# Enable DEBUG for troubleshooting. Examples:
# DEBUG: "primary:*"
# DEBUG: "worker:*"
Expand Down
3 changes: 3 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ paymentsProcessors:
nodeless:
baseURL: https://nodeless.io
storeId: your-nodeless-io-store-id
opennode:
baseURL: api.opennode.com
callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode
network:
maxPayloadSize: 524288
# Comment the next line if using CloudFlare proxy
Expand Down
6 changes: 6 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ export interface LNbitsPaymentsProcessor {
callbackBaseURL: string
}

export interface OpenNodePaymentsProcessor {
baseURL: string
callbackBaseURL: string
}

export interface NodelessPaymentsProcessor {
baseURL: string
storeId: string
Expand All @@ -177,6 +182,7 @@ export interface PaymentsProcessors {
zebedee?: ZebedeePaymentsProcessor
lnbits?: LNbitsPaymentsProcessor
nodeless?: NodelessPaymentsProcessor
opennode?: OpenNodePaymentsProcessor
}

export interface Local {
Expand Down
1 change: 0 additions & 1 deletion src/app/maintenance-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export class MaintenanceWorker implements IRunnable {
let successful = 0

for (const invoice of invoices) {
debug('invoice %s: %o', invoice.id, invoice)
try {
debug('getting invoice %s from payment processor: %o', invoice.id, invoice)
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice)
Expand Down
34 changes: 34 additions & 0 deletions src/controllers/callbacks/lnbits-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Request, Response } from 'express'

import { deriveFromSecret, hmacSha256 } from '../../utils/secret'
import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { getRemoteAddress } from '../../utils/http'
import { IController } from '../../@types/controllers'
import { IInvoiceRepository } from '../../@types/repositories'
import { IPaymentsService } from '../../@types/services'
Expand All @@ -22,6 +25,37 @@ export class LNbitsCallbackController implements IController {
debug('request headers: %o', request.headers)
debug('request body: %o', request.body)

const settings = createSettings()
const remoteAddress = getRemoteAddress(request, settings)
const paymentProcessor = settings.payments?.processor ?? 'null'

if (paymentProcessor !== 'lnbits') {
debug('denied request from %s to /callbacks/lnbits which is not the current payment processor', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

let validationPassed = false

if (typeof request.query.hmac === 'string' && request.query.hmac.match(/^[0-9]{1,20}:[0-9a-f]{64}$/)) {
const split = request.query.hmac.split(':')
if (hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') === split[1]) {
if (parseInt(split[0]) > Date.now()) {
validationPassed = true
}
}
}

if (!validationPassed) {
debug('unauthorized request from %s to /callbacks/lnbits', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const body = request.body
if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) {
response
Expand Down
24 changes: 24 additions & 0 deletions src/controllers/callbacks/nodeless-callback-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { fromNodelessInvoice } from '../../utils/transform'
import { hmacSha256 } from '../../utils/secret'
import { IController } from '../../@types/controllers'
import { IPaymentsService } from '../../@types/services'

Expand All @@ -22,6 +24,28 @@ export class NodelessCallbackController implements IController {
debug('callback request headers: %o', request.headers)
debug('callback request body: %O', request.body)

const settings = createSettings()
const paymentProcessor = settings.payments?.processor

const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex')
const actual = request.headers['nodeless-signature']

if (expected !== actual) {
console.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
response
.status(403)
.send('Forbidden')
return
}

if (paymentProcessor !== 'nodeless') {
debug('denied request from %s to /callbacks/nodeless which is not the current payment processor')
response
.status(403)
.send('Forbidden')
return
}

const nodelessInvoice = applySpec({
id: prop('uuid'),
status: prop('status'),
Expand Down
69 changes: 69 additions & 0 deletions src/controllers/callbacks/opennode-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { fromOpenNodeInvoice } from '../../utils/transform'
import { IController } from '../../@types/controllers'
import { IPaymentsService } from '../../@types/services'

const debug = createLogger('opennode-callback-controller')

export class OpenNodeCallbackController implements IController {
public constructor(
private readonly paymentsService: IPaymentsService,
) {}

// TODO: Validate
public async handleRequest(
request: Request,
response: Response,
) {
debug('request headers: %o', request.headers)
debug('request body: %O', request.body)

const invoice = fromOpenNodeInvoice(request.body)

debug('invoice', invoice)

let updatedInvoice: Invoice
try {
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
} catch (error) {
console.error(`Unable to persist invoice ${invoice.id}`, error)

throw error
}

if (
updatedInvoice.status !== InvoiceStatus.COMPLETED
&& !updatedInvoice.confirmedAt
) {
response
.status(200)
.send()

return
}

invoice.amountPaid = invoice.amountRequested
updatedInvoice.amountPaid = invoice.amountRequested

try {
await this.paymentsService.confirmInvoice({
id: invoice.id,
amountPaid: updatedInvoice.amountRequested,
confirmedAt: updatedInvoice.confirmedAt,
})
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
} catch (error) {
console.error(`Unable to confirm invoice ${invoice.id}`, error)

throw error
}

response
.status(200)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('OK')
}
}
44 changes: 36 additions & 8 deletions src/controllers/callbacks/zebedee-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { fromZebedeeInvoice } from '../../utils/transform'
import { getRemoteAddress } from '../../utils/http'
import { IController } from '../../@types/controllers'
import { InvoiceStatus } from '../../@types/invoice'
import { IPaymentsService } from '../../@types/services'

const debug = createLogger('zebedee-callback-controller')
Expand All @@ -21,23 +23,44 @@ export class ZebedeeCallbackController implements IController {
debug('request headers: %o', request.headers)
debug('request body: %O', request.body)

const settings = createSettings()

const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
const remoteAddress = getRemoteAddress(request, settings)
const paymentProcessor = settings.payments?.processor

if (ipWhitelist.length && !ipWhitelist.includes(remoteAddress)) {
debug('unauthorized request from %s to /callbacks/zebedee', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

if (paymentProcessor !== 'zebedee') {
debug('denied request from %s to /callbacks/zebedee which is not the current payment processor', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const invoice = fromZebedeeInvoice(request.body)

debug('invoice', invoice)

let updatedInvoice: Invoice
try {
if (invoice.bolt11) {
await this.paymentsService.updateInvoice(invoice)
}
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
} catch (error) {
console.error(`Unable to persist invoice ${invoice.id}`, error)

throw error
}

if (
invoice.status !== InvoiceStatus.COMPLETED
&& !invoice.confirmedAt
updatedInvoice.status !== InvoiceStatus.COMPLETED
&& !updatedInvoice.confirmedAt
) {
response
.status(200)
Expand All @@ -47,10 +70,15 @@ export class ZebedeeCallbackController implements IController {
}

invoice.amountPaid = invoice.amountRequested
updatedInvoice.amountPaid = invoice.amountRequested

try {
await this.paymentsService.confirmInvoice(invoice)
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
await this.paymentsService.confirmInvoice({
id: invoice.id,
confirmedAt: updatedInvoice.confirmedAt,
amountPaid: invoice.amountRequested,
})
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
} catch (error) {
console.error(`Unable to confirm invoice ${invoice.id}`, error)

Expand Down
34 changes: 34 additions & 0 deletions src/controllers/invoices/get-invoice-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { path, pathEq } from 'ramda'
import { Request, Response } from 'express'
import { readFileSync } from 'fs'

import { createSettings } from '../../factories/settings-factory'
import { FeeSchedule } from '../../@types/settings'
import { IController } from '../../@types/controllers'

let pageCache: string

export class GetInvoiceController implements IController {
public async handleRequest(
_req: Request,
res: Response,
): Promise<void> {
const settings = createSettings()

if (pathEq(['payments', 'enabled'], true, settings)
&& pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings)) {
if (!pageCache) {
const name = path<string>(['info', 'name'])(settings)
const feeSchedule = path<FeeSchedule>(['payments', 'feeSchedules', 'admission', '0'], settings)
pageCache = readFileSync('./resources/index.html', 'utf8')
.replaceAll('{{name}}', name)
.replaceAll('{{processor}}', settings.payments.processor)
.replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
}

res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache)
} else {
res.status(404).send()
}
}
}
3 changes: 3 additions & 0 deletions src/factories/controllers/get-invoice-controller-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { GetInvoiceController } from '../../controllers/invoices/get-invoice-controller'

export const createGetInvoiceController = () => new GetInvoiceController()
11 changes: 11 additions & 0 deletions src/factories/controllers/get-invoice-status-controller-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { GetInvoiceStatusController } from '../../controllers/invoices/get-invoice-status-controller'
import { getReadReplicaDbClient } from '../../database/client'
import { InvoiceRepository } from '../../repositories/invoice-repository'

export const createGetInvoiceStatusController = () => {
const rrDbClient = getReadReplicaDbClient()

const invoiceRepository = new InvoiceRepository(rrDbClient)

return new GetInvoiceStatusController(invoiceRepository)
}
Loading