feat: Add health api endpoint or kind (#166)

* feat: handle GET /healthz

* chore: simplify default settings

* chore: keep client alive on message

* chore: increase ws heartbeat timeout to 2min

* chore: improve logging during startup

* fix: zebedee callback crash on expired invoice

* fix: QR code csp error

* feat: add get-invoice-status-controller

* feat: add get-invoice-status-controller factory

* chore: refactor router

* feat: get invoice status using rest api

* fix: bad import

Signed-off-by: Ricardo Arturo Cabral Mejía <me@ricardocabral.io>

---------

Signed-off-by: Ricardo Arturo Cabral Mejía <me@ricardocabral.io>
This commit is contained in:
Ricardo Arturo Cabral Mejía 2023-02-04 02:02:01 -05:00 committed by GitHub
parent 3af69967e5
commit bfcdac51b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 183 additions and 73 deletions

View File

@ -15,13 +15,6 @@ payments:
whitelists:
pubkeys:
- replace-with-your-pubkey-in-hex
publication:
- enabled: false
description: Publication fee charged per event in msats (1000 msats = 1 satoshi)
amount: 10
whitelists:
pubkeys:
- replace-with-your-pubkey-in-hex
paymentsProcessors:
zebedee:
baseURL: https://api.zebedee.io/
@ -30,9 +23,8 @@ paymentsProcessors:
- "3.225.112.64"
- "::ffff:3.225.112.64"
network:
maxPayloadSize: 262144
maxPayloadSize: 524288
remoteIpHeader: x-forwarded-for
idleTimeout: 60
workers:
count: 0
mirroring:
@ -41,11 +33,9 @@ limits:
invoice:
rateLimits:
- period: 60000
rate: 3
rate: 6
- period: 3600000
rate: 10
- period: 86400000
rate: 20
rate: 16
ipWhitelist:
- "::1"
- "10.10.10.1"
@ -53,13 +43,11 @@ limits:
connection:
rateLimits:
- period: 1000
rate: 6
rate: 12
- period: 60000
rate: 30
rate: 48
- period: 3600000
rate: 300
- period: 86400000
rate: 1440
ipWhitelist:
- "::1"
- "10.10.10.1"
@ -159,25 +147,12 @@ limits:
maxFilters: 10
message:
rateLimits:
# - description: 60 subscriptions/min
# types:
# - REQ
# period: 60000
# rate: 60
# - description: 2880 subscriptions/hour
# types:
# - REQ
# period: 3600000
# rate: 2880
- description: 120 raw messages/min
- description: 240 raw messages/min
period: 60000
rate: 120
rate: 240
- description: 3600 raw messages/hour
period: 3600000
rate: 3600
- description: 86400 raw messages/day
period: 86400000
rate: 86400
rate: 4800
ipWhitelist:
- "::1"
- "10.10.10.1"

View File

@ -106,6 +106,40 @@
var expiresAt = "{{expires_at}}"
var timeout
var paid = false
var fallbackTimeout
function getBackoffTime() {
return 5000 + Math.floor(Math.random() * 5000)
}
async function getInvoiceStatus() {
fetch(`/invoices/${reference}/status`).then(async (response) => {
const data = await response.json()
console.log('data', data)
const { status } = data;
if (status === 'pending') {
fallbackTimeout = setTimeout(getInvoiceStatus, getBackoffTime())
return
} else if (status === 'expired') {
hide('pending')
show('expired')
return
}
paid = true
clearTimeout(timeout)
hide('pending')
show('paid')
}, (error) => {
console.error('error fetching status', error)
fallbackTimeout = setTimeout(getInvoiceStatus, getBackoffTime())
})
}
fallbackTimeout = setTimeout(getInvoiceStatus, getBackoffTime)
function connect() {
var socket = new WebSocket(relayUrl)
@ -137,22 +171,15 @@
}
}
break;
case 'EOSE': {
}
break;
}
if (!paid && message[0] === 'EOSE' && message[1] === 'payment') {
return
}
if (message.length !== 3 || message[0] !== 'EVENT' || message[1] !== 'payment') {
return
}
}
socket.onerror = console.error.bind(console)

View File

@ -147,6 +147,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
}
private async onClientMessage(raw: Buffer) {
this.alive = true
let abortable = false
let messageHandler: IMessageHandler & IAbortable | undefined = undefined
try {

View File

@ -14,7 +14,7 @@ import { WebServerAdapter } from './web-server-adapter'
const debug = createLogger('web-socket-server-adapter')
const WSS_CLIENT_HEALTH_PROBE_INTERVAL = 60000
const WSS_CLIENT_HEALTH_PROBE_INTERVAL = 120000
export class WebSocketServerAdapter extends WebServerAdapter implements IWebSocketServerAdapter {
private webSocketsAdapters: WeakMap<WebSocket, IWebSocketAdapter>
@ -51,7 +51,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
debug('closing')
clearInterval(this.heartbeatInterval)
this.webSocketServer.clients.forEach((webSocket: WebSocket) => {
debug('terminating client %s', this.webSocketsAdapters.get(webSocket).getClientId())
const webSocketAdapter = this.webSocketsAdapters.get(webSocket)
if (webSocketAdapter) {
debug('terminating client %s: %s', webSocketAdapter.getClientId(), webSocketAdapter.getClientAddress())
}
webSocket.terminate()
})
debug('closing web socket server')

View File

@ -93,15 +93,16 @@ export class App implements IRunnable {
}
}
logCentered(`${workerCount} workers started`, width)
logCentered(`${workerCount} client workers started`, width)
logCentered('1 maintenance worker started', width)
debug('settings: %O', settings)
const host = `${hostname()}:${port}`
addOnion(torHiddenServicePort, host).then(value=>{
console.info(`tor hidden service address: ${value}:${torHiddenServicePort}`)
logCentered(`Tor hidden service: ${value}:${torHiddenServicePort}`, width)
}, () => {
console.error('Unable to add Tor hidden service. Skipping.')
logCentered('Tor hidden service: disabled', width)
})
}

View File

@ -19,14 +19,16 @@ export class ZebedeeCallbackController implements IController {
response: Response,
) {
debug('request headers: %o', request.headers)
debug('request body: %o', request.body)
debug('request body: %O', request.body)
const invoice = fromZebedeeInvoice(request.body)
debug('invoice', invoice)
try {
await this.paymentsService.updateInvoice(invoice)
if (!invoice.bolt11) {
await this.paymentsService.updateInvoice(invoice)
}
} catch (error) {
console.error(`Unable to persist invoice ${invoice.id}`, error)

View File

@ -0,0 +1,50 @@
import { Request, Response } from 'express'
import { IController } from '../../@types/controllers'
import { IInvoiceRepository } from '../../@types/repositories'
export class GetInvoiceStatusController implements IController {
public constructor(
private readonly invoiceRepository: IInvoiceRepository,
) {}
public async handleRequest(
request: Request,
response: Response,
): Promise<void> {
const invoiceId = request.params.invoiceId
if (!invoiceId) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid invoice')
return
}
try {
const invoice = await this.invoiceRepository.findById(request.params.invoiceId)
if (!invoice) {
response
.status(404)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invoice not found')
return
}
response
.status(200)
.setHeader('content-type', 'application/json; charset=utf8')
.send(JSON.stringify({
id: invoice.id,
status: invoice.status,
}))
} catch (error) {
console.error(`get-invoice-status-controller: unable to get invoice ${invoiceId}:`, error)
response
.status(500)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Unable to get invoice status')
}
}
}

View File

@ -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)
}

View File

@ -0,0 +1,45 @@
import express from 'express'
import helmet from 'helmet'
import { createLogger } from './logger-factory'
import { createSettings } from './settings-factory'
import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware'
import router from '../routes'
const debug = createLogger('web-app-factory')
export const createWebApp = () => {
const app = express()
app
.disable('x-powered-by')
.use(rateLimiterMiddleware)
.use((req, res, next) => {
const settings = createSettings()
const relayUrl = new URL(settings.info.relay_url)
const webRelayUrl = new URL(relayUrl.toString())
webRelayUrl.protocol = (relayUrl.protocol === 'wss:') ? 'https:' : ':'
const directives = {
/**
* TODO: Remove 'unsafe-inline'
*/
'img-src': ["'self'", 'data:', 'https://cdn.zebedee.io/an/nostr/'],
'connect-src': ["'self'", settings.info.relay_url as string, webRelayUrl.toString()],
'default-src': ['"self"'],
'script-src-attr': ["'unsafe-inline'"],
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/', 'https://cdnjs.cloudflare.com/ajax/libs/'],
'style-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
}
debug('CSP directives: %o', directives)
return helmet.contentSecurityPolicy({ directives })(req, res, next)
})
.use('/favicon.ico', express.static('./resources/favicon.ico'))
.use('/css', express.static('./resources/css'))
.use(router)
return app
}

View File

@ -1,6 +1,4 @@
import { is, path, pathSatisfies } from 'ramda'
import express from 'express'
import helmet from 'helmet'
import http from 'http'
import process from 'process'
import { WebSocketServer } from 'ws'
@ -8,9 +6,8 @@ import { WebSocketServer } from 'ws'
import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
import { AppWorker } from '../app/worker'
import { createSettings } from '../factories/settings-factory'
import { createWebApp } from './web-app-factory'
import { EventRepository } from '../repositories/event-repository'
import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware'
import router from '../routes'
import { UserRepository } from '../repositories/user-repository'
import { webSocketAdapterFactory } from './websocket-adapter-factory'
import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter'
@ -23,27 +20,7 @@ export const workerFactory = (): AppWorker => {
const settings = createSettings()
const app = express()
app
.disable('x-powered-by')
.use(rateLimiterMiddleware)
.use(helmet.contentSecurityPolicy({
directives: {
/**
* TODO: Remove 'unsafe-inline'
*/
'img-src': ["'self'", 'https://cdn.zebedee.io/an/nostr/'],
'connect-src': [settings.info.relay_url as string],
'default-src': ['"self"'],
'script-src-attr': ["'unsafe-inline'"],
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/', 'https://cdnjs.cloudflare.com/ajax/libs/'],
'style-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
},
}))
.use('/favicon.ico', express.static('./resources/favicon.ico'))
.use('/css', express.static('./resources/css'))
.use(router)
const app = createWebApp()
// deepcode ignore HttpToHttps: we use proxies
const server = http.createServer(app)

View File

@ -0,0 +1,6 @@
import { NextFunction, Request, Response } from 'express'
export const getHealthRequestHandler = (_req: Request, res: Response, next: NextFunction) => {
res.status(200).setHeader('content-type', 'text/plain; charset=utf8').send('OK')
next()
}

View File

@ -0,0 +1,9 @@
import { Request, Response } from 'express'
import { createGetInvoiceStatusController } from '../../factories/get-invoice-status-controller-factory'
export const getInvoiceStatusRequestHandler = async (req: Request, res: Response) => {
const controller = createGetInvoiceStatusController()
await controller.handleRequest(req, res)
}

View File

@ -1,6 +1,7 @@
import express from 'express'
import callbacksRouter from './callbacks'
import { getHealthRequestHandler } from '../handlers/request-handlers/get-health-request-handler'
import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler'
import invoiceRouter from './invoices'
import { rootRequestHandler } from '../handlers/request-handlers/root-request-handler'
@ -8,10 +9,10 @@ import { rootRequestHandler } from '../handlers/request-handlers/root-request-ha
const router = express.Router()
router.get('/', rootRequestHandler)
router.get('/healthz', getHealthRequestHandler)
router.get('/terms', getTermsRequestHandler)
router.use('/invoices', invoiceRouter)
router.use('/callbacks', callbacksRouter)
export default router

View File

@ -2,6 +2,7 @@ import { Router, urlencoded } from 'express'
import { createPaymentsProcessor } from '../../factories/payments-processor-factory'
import { getInvoiceRequestHandler } from '../../handlers/request-handlers/get-invoice-request-handler'
import { getInvoiceStatusRequestHandler } from '../../handlers/request-handlers/get-invoice-status-request-handler'
import { postInvoiceRequestHandler } from '../../handlers/request-handlers/post-invoice-request-handler'
const invoiceRouter = Router()
@ -12,6 +13,7 @@ invoiceRouter
next()
})
.get('/', getInvoiceRequestHandler)
.get('/:invoiceId/status', getInvoiceStatusRequestHandler)
.post('/', urlencoded({ extended: true }), postInvoiceRequestHandler)
export default invoiceRouter