feat: massive update

Signed-off-by: Ricardo Arturo Cabral Mejía <me@ricardocabral.io>
This commit is contained in:
Ricardo Arturo Cabral Mejía 2023-01-27 02:57:20 -05:00
parent 55561847e8
commit f9c53eeeb8
80 changed files with 2131 additions and 682 deletions

View File

@ -18,13 +18,6 @@ LABEL org.opencontainers.image.description="nostream"
LABEL org.opencontainers.image.authors="Ricardo Arturo Cabral Mejía"
LABEL org.opencontainers.image.licenses=MIT
ENV DB_HOST=localhost
ENV DB_PORT=5432
ENV DB_NAME=nostr-ts-relay
ENV DB_USER=nostr-ts-relay
ENV DB_PASSWORD=nostr-ts-relay
WORKDIR /app
RUN apk add --no-cache --update git

View File

@ -1,13 +1,13 @@
services:
relay:
nostream:
build: .
container_name: nostr-ts-relay
container_name: nostream
environment:
SECRET: changeme
RELAY_PORT: 8008
NOSTR_CONFIG_DIR: /home/node/
# Master
DB_HOST: db
NOSTR_CONFIG_DIR: /home/node/.nostr
DB_HOST: nostream-db
DB_PORT: 5432
DB_USER: nostr_ts_relay
DB_PASSWORD: nostr_ts_relay
@ -26,7 +26,7 @@ services:
RR_DB_MAX_POOL_SIZE: 64
RR_DB_ACQUIRE_CONNECTION_TIMEOUT: 60000
# Redis
REDIS_HOST: cache
REDIS_HOST: nostream-cache
REDIS_PORT: 6379
REDIS_USER: default
REDIS_PASSWORD: nostr_ts_relay
@ -38,31 +38,31 @@ services:
# DEBUG: "primary:*"
# DEBUG: "worker:*"
# DEBUG: "knex:query"
env_file:
- test.env
user: node:node
volumes:
- ${PWD}/.nostr:/home/node/
- ${PWD}/.nostr:/home/node/.nostr
ports:
- 8008:8008
depends_on:
cache:
nostream-cache:
condition: service_healthy
db:
nostream-db:
condition: service_healthy
migrations:
nostream-migrate:
condition: service_completed_successfully
restart: on-failure
networks:
default:
ipv4_address: 10.10.10.2
db:
nostream-db:
image: postgres
container_name: db
container_name: nostream-db
environment:
POSTGRES_DB: nostr_ts_relay
POSTGRES_USER: nostr_ts_relay
POSTGRES_PASSWORD: nostr_ts_relay
volumes:
- pgdata:/var/lib/postgresql/data-old
- ${PWD}/.nostr/data:/var/lib/postgresql/data
- ${PWD}/.nostr/db-logs:/var/log/postgresql
- ${PWD}/postgresql.conf:/postgresql.conf
@ -70,7 +70,6 @@ services:
- 15432:5432
networks:
default:
ipv4_address: 10.10.10.3
command: postgres -c 'config_file=/postgresql.conf'
restart: always
healthcheck:
@ -79,26 +78,25 @@ services:
timeout: 5s
retries: 5
start_period: 360s
cache:
nostream-cache:
image: redis:7.0.5-alpine3.16
container_name: cache
container_name: nostream-cache
volumes:
- cache:/data
command: redis-server --loglevel warning --requirepass nostr_ts_relay
networks:
default:
ipv4_address: 10.10.10.4
restart: always
healthcheck:
test: [ "CMD", "redis-cli", "ping", "|", "grep", "PONG" ]
interval: 1s
timeout: 5s
retries: 5
migrations:
nostream-migrate:
image: node:18-alpine3.16
container_name: migrations
container_name: nostream-migrate
environment:
DB_HOST: db
DB_HOST: nostream-db
DB_PORT: 5432
DB_USER: nostr_ts_relay
DB_PASSWORD: nostr_ts_relay
@ -111,14 +109,15 @@ services:
- ./migrations:/code/migrations
- ./knexfile.js:/code/knexfile.js
depends_on:
db:
nostream-db:
condition: service_healthy
networks:
default:
ipv4_address: 10.10.10.254
networks:
default:
name: nostr-ts-relay
name: nostream
ipam:
driver: default
config:
@ -126,4 +125,3 @@ networks:
volumes:
cache:
pgdata:

View File

@ -6,7 +6,7 @@ exports.up = function (knex) {
table.bigint('amount_requested').unsigned().notNullable()
table.bigint('amount_paid').unsigned()
table.enum('unit', ['msats', 'sats', 'btc'])
table.enum('status', ['pending', 'completed'])
table.enum('status', ['pending', 'completed', 'expired'])
table.text('description')
table.datetime('confirmed_at', { useTz: false, precision: 3 })
table.datetime('expires_at', { useTz: false, precision: 3 })

View File

@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.schema.createTable('users', (table) => {
table.binary('pubkey').primary()
table.boolean('is_admitted').default(0)
table.bigint('balance').default(0)
table.datetime('tos_accepted_at', { useTz: false, precision: 3 })
table.timestamps(true, true, false)
})
}
exports.down = function (knex) {
return knex.schema.dropTable('users')
}

View File

@ -0,0 +1,70 @@
// Adapted from: https://github.com/stackernews/stacker.news
// Original Author: Keyan Kousha https://github.com/huumn
/**
MIT License
Copyright (c) 2023 Keyan Kousha / Stacker News
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
exports.up = async function (knex) {
return knex.schema
.raw(`create function now_utc() returns timestamp as $$
select now() at time zone 'utc';
$$ language sql;`)
.raw(`create function ASSERT_SERIALIZED() returns void as $$
BEGIN
IF (select current_setting('transaction_isolation') <> 'serializable') THEN
RAISE EXCEPTION 'SN_NOT_SERIALIZABLE';
END IF;
END;
$$ language plpgsql;
`)
.raw(
`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id UUID, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
payee BYTEA;
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
IF confirmed_date IS NULL THEN
UPDATE invoices
SET
"confirmed_at" = confirmation_date,
"amount_paid" = amount_received,
"updated_at" = now_utc()
WHERE id = invoice_id;
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
END IF;
RETURN 0;
END;
$$;`)
}
exports.down = function (knex) {
return knex.schema
.raw('DROP FUNCTION IF EXISTS confirm_invoice(UUID, BYTEA, TIMESTAMP);')
.raw('DROP FUNCTION IF EXISTS ASSERT_SERIALIZED();')
.raw('DROP FUNCTION IF EXISTS now_utc();')
}

View File

@ -0,0 +1,14 @@
exports.up = async function (knex) {
return knex.schema
.raw('CREATE EXTENSION btree_gin;')
.raw(
`CREATE INDEX kind_tags_created_at_idx
ON events USING GIN ( event_kind, event_tags, event_created_at );`,
)
}
exports.down = function (knex) {
return knex.schema
.raw('DROP INDEX IF EXISTS kind_tags_created_at_idx;')
.raw('DROP EXTENSION btree_gin;')
}

View File

@ -0,0 +1,27 @@
exports.up = async function (knex) {
return knex.schema
.raw(
`CREATE OR REPLACE FUNCTION charge_user(charged_user BYTEA, amount BIGINT)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
current_balance BIGINT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT "balance" INTO current_balance FROM "users" WHERE "pubkey" = charged_user;
IF current_balance - amount >= 0 THEN
UPDATE "users" SET balance = balance - amount WHERE "pubkey" = charged_user;
RETURN 1;
ELSE
RETURN 0;
END IF;
END;
$$;`)
}
exports.down = function (knex) {
return knex.schema
.raw('DROP FUNCTION IF EXISTS charge_user(BYTEA, BIGINT);')
}

View File

@ -0,0 +1,9 @@
exports.up = function (knex) {
return knex.raw('ALTER TABLE events ADD remote_address inet NULL;')
}
exports.down = function (knex) {
return knex.schema.alterTable('events', function (table) {
table.dropColumn('remote_address')
})
}

77
package-lock.json generated
View File

@ -14,6 +14,7 @@
"bech32": "2.0.0",
"body-parser": "1.20.1",
"debug": "4.3.4",
"dinero.js": "2.0.0-alpha.13",
"dotenv": "16.0.3",
"express": "4.18.2",
"helmet": "6.0.1",
@ -44,7 +45,7 @@
"@types/express": "4.17.15",
"@types/js-yaml": "4.0.5",
"@types/mocha": "^9.1.1",
"@types/node": "^17.0.24",
"@types/node": "18.11.18",
"@types/pg": "^8.6.5",
"@types/ramda": "^0.28.13",
"@types/sinon": "^10.0.11",
@ -1072,6 +1073,27 @@
"integrity": "sha512-chTnjxV3vryL75N90wJIMdMafXmZoO2JgNJLYpsfcALL2/IQrRiny3vM9DgD5RDCSt1LNloMtb7rGey9YWxCsA==",
"dev": true
},
"node_modules/@dinero.js/calculator-number": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/@dinero.js/calculator-number/-/calculator-number-2.0.0-alpha.13.tgz",
"integrity": "sha512-Mo/0FUVFq4dVb/RfBIASUxPFbQbkHYC0VBe36DgWfbbhwG6K6ONuzTLBU5W6LdrMpB7D/CxgZISdQ0pyZKwg9w==",
"dependencies": {
"@dinero.js/core": "2.0.0-alpha.13"
}
},
"node_modules/@dinero.js/core": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/@dinero.js/core/-/core-2.0.0-alpha.13.tgz",
"integrity": "sha512-RLZz8WmYe1ehqD2YtTpzCzdk0Hqf6oaAVmRlULLkua6vuEVEXjPQEhuyTMLHcS6/Gppy9nuEw8mpSNvU+Sy8QQ==",
"dependencies": {
"@dinero.js/currencies": "2.0.0-alpha.13"
}
},
"node_modules/@dinero.js/currencies": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/@dinero.js/currencies/-/currencies-2.0.0-alpha.13.tgz",
"integrity": "sha512-vEfDara+xAqOrLww80FyFVw4usWcCsR4HwhaoCvwIzB5UdsLPDeUviAGFi8CoWjtLCR44iIeOHdn9jDr7Jj+TQ=="
},
"node_modules/@eslint/eslintrc": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz",
@ -1893,9 +1915,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "17.0.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.24.tgz",
"integrity": "sha512-aveCYRQbgTH9Pssp1voEP7HiuWlD2jW2BO56w+bVrJn04i61yh6mRfoKO6hEYQD9vF+W8Chkwc6j1M36uPkx4g==",
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==",
"dev": true
},
"node_modules/@types/normalize-package-data": {
@ -3968,6 +3990,16 @@
"node": ">=0.3.1"
}
},
"node_modules/dinero.js": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/dinero.js/-/dinero.js-2.0.0-alpha.13.tgz",
"integrity": "sha512-xuBgE/3/67SXOW/UKjmaRd/YJQkUSlYS+6DvfglFg1Mbfn2K7cp+WQcgU76CLJpJPkJABgILsPjw1Om9OJ5puA==",
"dependencies": {
"@dinero.js/calculator-number": "2.0.0-alpha.13",
"@dinero.js/core": "2.0.0-alpha.13",
"@dinero.js/currencies": "2.0.0-alpha.13"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -14003,6 +14035,27 @@
"integrity": "sha512-chTnjxV3vryL75N90wJIMdMafXmZoO2JgNJLYpsfcALL2/IQrRiny3vM9DgD5RDCSt1LNloMtb7rGey9YWxCsA==",
"dev": true
},
"@dinero.js/calculator-number": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/@dinero.js/calculator-number/-/calculator-number-2.0.0-alpha.13.tgz",
"integrity": "sha512-Mo/0FUVFq4dVb/RfBIASUxPFbQbkHYC0VBe36DgWfbbhwG6K6ONuzTLBU5W6LdrMpB7D/CxgZISdQ0pyZKwg9w==",
"requires": {
"@dinero.js/core": "2.0.0-alpha.13"
}
},
"@dinero.js/core": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/@dinero.js/core/-/core-2.0.0-alpha.13.tgz",
"integrity": "sha512-RLZz8WmYe1ehqD2YtTpzCzdk0Hqf6oaAVmRlULLkua6vuEVEXjPQEhuyTMLHcS6/Gppy9nuEw8mpSNvU+Sy8QQ==",
"requires": {
"@dinero.js/currencies": "2.0.0-alpha.13"
}
},
"@dinero.js/currencies": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/@dinero.js/currencies/-/currencies-2.0.0-alpha.13.tgz",
"integrity": "sha512-vEfDara+xAqOrLww80FyFVw4usWcCsR4HwhaoCvwIzB5UdsLPDeUviAGFi8CoWjtLCR44iIeOHdn9jDr7Jj+TQ=="
},
"@eslint/eslintrc": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz",
@ -14679,9 +14732,9 @@
"dev": true
},
"@types/node": {
"version": "17.0.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.24.tgz",
"integrity": "sha512-aveCYRQbgTH9Pssp1voEP7HiuWlD2jW2BO56w+bVrJn04i61yh6mRfoKO6hEYQD9vF+W8Chkwc6j1M36uPkx4g==",
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==",
"dev": true
},
"@types/normalize-package-data": {
@ -16238,6 +16291,16 @@
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true
},
"dinero.js": {
"version": "2.0.0-alpha.13",
"resolved": "https://registry.npmjs.org/dinero.js/-/dinero.js-2.0.0-alpha.13.tgz",
"integrity": "sha512-xuBgE/3/67SXOW/UKjmaRd/YJQkUSlYS+6DvfglFg1Mbfn2K7cp+WQcgU76CLJpJPkJABgILsPjw1Om9OJ5puA==",
"requires": {
"@dinero.js/calculator-number": "2.0.0-alpha.13",
"@dinero.js/core": "2.0.0-alpha.13",
"@dinero.js/currencies": "2.0.0-alpha.13"
}
},
"dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",

View File

@ -82,7 +82,7 @@
"@types/express": "4.17.15",
"@types/js-yaml": "4.0.5",
"@types/mocha": "^9.1.1",
"@types/node": "^17.0.24",
"@types/node": "18.11.18",
"@types/pg": "^8.6.5",
"@types/ramda": "^0.28.13",
"@types/sinon": "^10.0.11",
@ -116,6 +116,7 @@
"bech32": "2.0.0",
"body-parser": "1.20.1",
"debug": "4.3.4",
"dinero.js": "2.0.0-alpha.13",
"dotenv": "16.0.3",
"express": "4.18.2",
"helmet": "6.0.1",

View File

@ -22,10 +22,12 @@ payments:
whitelists:
pubkeys:
- replace-with-your-pubkey
paymentProcessors:
paymentsProcessors:
zebedee:
baseURL: https://api.zebedee.io/
callbackBaseURL: https://nostream.your-domain.com/callbacks/zebedee
ipWhitelist:
- "::ffff:3.225.112.64"
network:
maxPayloadSize: 131072
remoteIpHeader: x-forwarded-for
@ -36,22 +38,24 @@ limits:
invoice:
rateLimits:
- period: 60000
rate: 1
rate: 3
- period: 3600000
rate: 30
rate: 10
- period: 86400000
rate: 360
rate: 20
ipWhitelist:
- "::1"
- "::ffff:10.10.10.1"
connection:
rateLimits:
- period: 1000
rate: 6
- period: 60000
rate: 12
rate: 30
- period: 3600000
rate: 360
rate: 300
- period: 86400000
rate: 2880
rate: 1440
ipWhitelist:
- "::1"
- "::ffff:10.10.10.1"
@ -62,7 +66,7 @@ limits:
whitelist: []
blacklist: []
pubkey:
minBalanceMsats: 0
minBalance: 0
minLeadingZeroBits: 0
whitelist: []
blacklist: []

View File

@ -3,12 +3,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pay To Relay - {{name}}</title>
<title>Admission Fee Required - {{name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="./css/style.css">
<style>
</style>
</head>
<body lang="en" onload="onLoad()">
<main class="container">
@ -33,7 +30,7 @@
<div class="card-body">
<div class="d-flex flex-column mb-4">
<label for="pubkey" class="h5">Your Nostr public key</label>
<input type="text" name="pubkey" class="form-control form-control-sm" id="pubkey" placeholder="npub... or hex..." pattern="^(npub|[0-9a-f]{64})" required>
<input type="text" name="pubkey" class="form-control form-control-sm" id="pubkey" placeholder="npub... or hex..." pattern="^([0-9a-f]{64}|npub1[ac-hj-np-z02-9]+)$" required>
<div id="pubkeyAfterHelpBlock" class="form-text">
Hex or npub formats accepted.
</div>
@ -49,6 +46,7 @@
</div>
<div class="row">
<div class="d-flex justify-content-center mb-3">
<input type="hidden" name="feeSchedule" value="admission" />
<button id="submitBtn" class="btn btn-lg btn-warning" type="submit">Pay {{amount}} sats</button>
</div>
</div>
@ -116,9 +114,9 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
<script>
function onLoad() {
function attemptGetPubkey() {
const maxRetries = 10
const getPubKey = (retries) => {
function getPubKey(retries) {
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
window.nostr.getPublicKey().then((pubkey) => {
console.log(pubkey)
@ -128,8 +126,13 @@
setTimeout(() => getPubKey(retries - 1), 100)
}
}
getPubKey(maxRetries)
}
function onLoad() {
setTimeout(attemptGetPubkey, 300)
}
</script>
</body>
</html>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pay Invoice - {{name}}</title>
<title>Invoice Payment - {{name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="./css/style.css">
<script
@ -14,115 +14,174 @@
</head>
<body lang="en">
<main class="container">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
</div>
</div>
<div class="row">
<div class="col text-center">
<p class="pending">
Scan with your bitcoin lightning wallet
</p>
<p class="paid text-success d-none">
Your payment has been received
</p>
</div>
</div>
<div class="row justify-content-center">
<div class="card pending col-8 col-lg-4 d-flex flex-column justify-content-center mb-4">
<div class="card-body m-auto">
<div id="invoice" onclick="sendPayment()"></div>
</div>
<div class="card-body d-flex flex-row justify-content-center">
<div class="input-group input-group-sm w-100 mw-256" onclick="copy()">
<input type="text" name="invoice" class="form-control form-control-sm" id="invoiceInput" value="{{invoice}}" readonly>
<span class="input-group-text" id="invoiceAlert">copy</span>
</div>
</div>
<div class="card-body d-flex flex-row justify-content-center">
<div id="waiting">
<div class="spinner-grow spinner-grow-sm" role="status"></div>
Waiting for payment
</div>
<div id="paid" class="hidden">
✅ Payment received
</div>
<div id="timeout" class="hidden">
❌ Invoice expired
</div>
<form method="post" action="/invoices">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
</div>
</div>
<div class="card paid d-none col-8 col-lg-4 justify-content-center">
<div class="card-body text-center">
<div class="success-checkmark">
<div class="check-icon">
<span class="icon-line line-tip"></span>
<span class="icon-line line-long"></span>
<div class="icon-circle"></div>
<div class="icon-fix"></div>
<div class="row">
<div class="col text-center">
<p class="pending">
Scan with your bitcoin lightning wallet
</p>
<p class="paid d-none text-success">
You may connect to {{relay_url}}
</p>
<p class="expired d-none text-secondary">
Your invoice expired
</p>
</div>
</div>
<div class="row justify-content-center">
<div class="card pending col-8 col-lg-4 d-flex flex-column justify-content-center mb-4">
<div class="card-body m-auto">
<div id="invoice" onclick="sendPayment()"></div>
</div>
<div class="card-body d-flex flex-row justify-content-center">
<div class="input-group input-group-sm w-100 mw-256" onclick="copy()">
<input type="text" name="invoice" class="form-control form-control-sm" id="invoiceInput" value="{{invoice}}" readonly>
<span class="input-group-text" id="invoiceAlert">copy</span>
</div>
</div>
<h2 class="text-success">Payment successful</h2>
<p class="text-secondary">{{amount}} sats received</p>
<div class="card-body d-flex flex-row justify-content-center">
<div>
<div class="spinner-grow spinner-grow-sm" role="status"></div>
Waiting for payment
</div>
</div>
</div>
<div class="card paid d-none col-8 col-lg-4 justify-content-center">
<div class="card-body text-center">
<div class="success-checkmark">
<div class="check-icon">
<span class="icon-line line-tip"></span>
<span class="icon-line line-long"></span>
<div class="icon-circle"></div>
<div class="icon-fix"></div>
</div>
</div>
<h2 class="text-success">Payment successful</h2>
<p class="text-secondary">{{amount}} sats received</p>
</div>
</div>
<div class="card expired d-none col-8 col-lg-4 justify-content-center mb-4">
<div class="card-body text-center">
<h2 class="text-danger">Invoice expired</h2>
</div>
</div>
</div>
<div class="card expired col-8 col-lg-4 justify-content-center">
<div class="card-body text-center">
<h2 class="text-warning">Invoice expired</h2>
<div class="row pending d-none">
<div class="col">
<div class="d-flex justify-content-center mb-3">
<button id="sendPaymentBtn" class="btn btn-lg btn-warning d-none" type="submit" onclick="sendPayment()">Pay with wallet</button>
</div>
</div>
</div>
</div>
<div class="row pending">
<div class="col">
<div class="row expired d-none">
<div class="d-flex justify-content-center mb-3">
<button id="sendPaymentBtn" class="btn btn-lg btn-warning d-none" type="submit" onclick="sendPayment()">Pay with wallet</button>
<input type="hidden" name="pubkey" value="{{pubkey}}" required>
<input type="checkbox" class="d-none" name="tosAccepted" value="yes" checked required>
<input type="hidden" name="feeSchedule" value="admission" />
<button class="btn btn-lg btn-primary" type="submit">Get another invoice</button>
</div>
</div>
</div>
</form>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
var reference = "{{reference}}"
var relayUrl = "{{relay_url}}"
var relayPubkey = "{{relay_pubkey}}"
var invoice = "{{invoice}}";
var pubkey = "{{pubkey}}"
var expiresAt = "{{expires_at}}"
var timeout
var paid = false
function connect() {
var socket = new WebSocket(relayUrl)
socket.onopen = () => {
console.log('connected')
socket.send(JSON.stringify(['REQ', 'payment', { kinds: [402], authors: [relayPubkey] }]))
socket.send(JSON.stringify(['REQ', 'payment', { kinds: [4], authors: [relayPubkey], '#c': [reference], limit: 1 }]))
}
socket.onmessage = (raw) => {
const message = JSON.parse(raw.data)
console.log('received', message)
if (!Array.isArray(message) || message.length !== 3 || message[0] !== 'EVENT' || message[1] !== 'payment') {
if (!Array.isArray(message) || message.length < 2 || message[1] !== 'payment') {
return
}
// hide waiting
const pendingElements = document.getElementsByClassName('pending')
const paidElements = document.getElementsByClassName('paid')
for (const elem of pendingElements) {
elem.classList.add('d-none')
switch (message[0]) {
case 'EVENT': {
// TODO: validate event
const event = message[2]
// TODO: validate signature
if (event.pubkey === relayPubkey) {
paid = true
clearTimeout(timeout)
hide('pending')
show('paid')
}
}
break;
case 'EOSE': {
}
break;
}
for (const elem of paidElements) {
elem.classList.remove('d-none')
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)
socket.onclose = () => {
console.log('disconnected')
setTimeout(connect, 0)
setTimeout(connect, 1000)
}
}
function show(className) {
return toggle(className, true)
}
function hide(className) {
return toggle(className, false)
}
function toggle(className, show) {
const elements = document.getElementsByClassName(className)
for (const elem of elements) {
if (show) {
elem.classList.remove('d-none')
} else {
elem.classList.add('d-none')
}
}
}
const expiry = (new Date(expiresAt).getTime() - new Date().getTime())
console.log('expiry at', expiresAt, Math.floor(expiry / 1000))
timeout = setTimeout(() => {
hide('pending')
show('expired')
}, expiry)
new QRCode(document.getElementById("invoice"), {
text: `lightning:${invoice}`,
width: 256,
@ -139,8 +198,7 @@
}
async function sendPayment() {
const webln = await WebLN.requestProvider();
//webln.sendPayment(invoice)
webln.sendPayment('lnbc10u1p3menvwpp5gtjz9n8vvwpeeav7884rzx2g0mlgcxy8rjkcxwmxscxcqmfg690qdpuxycrqvpqwdshgueqvehhygzqvdsk6etjdysx7m3qwd6xzcmtv4ezumn9waescqzpgxqr230sp5y3rj64jfchnhvequdreh5yrh8yr7lle9s3mr85wvhlqgmvdnd2xq9qyyssqc8spqduevg3s04j3975mt8jp3frsma87uqa85tra50dhf9xc5rwkwu6lh677t5zz9laeg20pgyedg24xck7lfjygyftslaax2ht9jqcp62y039')
webln.sendPayment(invoice)
}
connect()
sendPayment().catch(() => {

View File

@ -1,6 +1,24 @@
#!/bin/bash
PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.."
DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml"
NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr"
SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml"
DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml"
if [ "$EUID" -eq 0 ]
then echo "Error: Nostream should not be run as root."
exit 1
fi
if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then
echo "Creating folder ${NOSTR_CONFIG_DIR}"
mkdir -p "${NOSTR_CONFIG_DIR}"
fi
if [[ ! -f "${SETTINGS_FILE}" ]]; then
echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}"
cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}"
fi
docker compose \
-f $DOCKER_COMPOSE_FILE \

View File

@ -3,6 +3,24 @@ PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.."
DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml"
DOCKER_COMPOSE_TOR_FILE="${PROJECT_ROOT}/docker-compose.tor.yml"
TOR_DATA_DIR="$PROJECT_ROOT/.nostr/tor/data"
NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr"
SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml"
DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml"
if [ "$EUID" -eq 0 ]
then echo "Error: Nostream should not be run as root."
exit 1
fi
if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then
echo "Creating folder ${NOSTR_CONFIG_DIR}"
mkdir -p "${NOSTR_CONFIG_DIR}"
fi
if [[ ! -f "${SETTINGS_FILE}" ]]; then
echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}"
cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}"
fi
mkdir -p $TOR_DATA_DIR

View File

@ -18,6 +18,9 @@ export type IWebSocketAdapter = EventEmitter & {
}
export interface ICacheAdapter {
getKey(key: string): Promise<string>
hasKey(key: string): Promise<boolean>
setKey(key: string, value: string): Promise<boolean>
addToSortedSet(key: string, set: Record<string, string> | Record<string, string>[]): Promise<number>
removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise<number>
getRangeFromSortedSet(key: string, start: number, stop: number): Promise<string[]>

View File

@ -1,4 +1,5 @@
import { Knex } from 'knex'
import { SocketAddress } from 'net'
export type EventId = string
export type Pubkey = string
@ -27,6 +28,13 @@ export type Factory<TOutput = any, TInput = void> = (input: TInput) => TOutput
export type DatabaseClient = Knex
export type DatabaseTransaction<T = any> = Knex.Transaction<T, T[]>
export interface ContextMetadata {
remoteAddress: SocketAddress
}
export interface IRunnable {
run(): void
close(callback?: (...args: any[]) => void): void
}

View File

@ -1,19 +1,28 @@
export interface InvoiceEnvelope {
bolt11: string
}
import { Invoice, InvoiceStatus, InvoiceUnit } from './invoice'
export interface CreateInvoiceResponse {
externalReference: string
amount: number
invoice: InvoiceEnvelope
id: string
pubkey: string
bolt11: string
amountRequested: bigint
description: string
unit: InvoiceUnit
status: InvoiceStatus
expiresAt: Date | null
confirmedAt?: Date | null
createdAt: Date
rawResponse?: string
}
export interface CreateInvoiceRequest {
amountMsats: number
amount: bigint
description?: string
requestId?: string
}
export type GetInvoiceResponse = Invoice
export interface IPaymentsProcessor {
createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse>
getInvoice(invoiceId: string): Promise<GetInvoiceResponse>
}

8
src/@types/database.ts Normal file
View File

@ -0,0 +1,8 @@
import { DatabaseTransaction } from './base'
export interface ITransaction {
begin(): Promise<void>
get transaction (): DatabaseTransaction
commit(): Promise<any[]>
rollback(): Promise<any[]>
}

View File

@ -1,6 +1,5 @@
import { EventDeduplicationMetadataKey, EventDelegatorMetadataKey, EventKinds } from '../constants/base'
import { EventId, Pubkey, Tag } from './base'
import { ContextMetadata, EventId, Pubkey, Tag } from './base'
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventDelegatorMetadataKey, EventKinds } from '../constants/base'
export interface Event {
id: EventId
@ -10,6 +9,7 @@ export interface Event {
tags: Tag[]
sig: string
content: string
[ContextMetadataKey]?: ContextMetadata
}
export type UnsignedEvent = Omit<Event, 'sig'>

View File

@ -16,12 +16,12 @@ export interface Invoice {
pubkey: Pubkey
bolt11: string
amountRequested: bigint
amountPaid: bigint
amountPaid?: bigint
unit: InvoiceUnit
status: InvoiceStatus
description: string
confirmedAt: Date
expiresAt: Date
confirmedAt?: Date | null
expiresAt: Date | null
updatedAt: Date
createdAt: Date
}

View File

@ -1,5 +1,6 @@
import { EventId, Range } from './base'
import { ContextMetadata, EventId, Range } from './base'
import { SubscriptionFilter, SubscriptionId } from './subscription'
import { ContextMetadataKey } from '../constants/base'
import { Event } from './event'
export enum MessageType {
@ -11,10 +12,13 @@ export enum MessageType {
OK = 'OK'
}
export type IncomingMessage =
export type IncomingMessage = (
| SubscribeMessage
| IncomingEventMessage
| UnsubscribeMessage
) & {
[ContextMetadataKey]?: ContextMetadata
}
export type OutgoingMessage =

View File

@ -1,9 +1,10 @@
import { PassThrough } from 'stream'
import { DatabaseClient, EventId, Pubkey } from './base'
import { DBEvent, Event } from './event'
import { EventId, Pubkey } from './base'
import { Invoice } from './invoice'
import { SubscriptionFilter } from './subscription'
import { User } from './user'
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'
@ -20,6 +21,23 @@ export interface IEventRepository {
}
export interface IInvoiceRepository {
findById(id: string): Promise<Invoice | undefined>
upsert(invoice: Invoice): Promise<number>
findById(id: string, client?: DatabaseClient): Promise<Invoice | undefined>
upsert(invoice: Partial<Invoice>, client?: DatabaseClient): Promise<number>
confirmInvoice(
invoiceId: string,
amountReceived: bigint,
confirmedAt: Date,
client?: DatabaseClient,
): Promise<void>
findPendingInvoices(
offset?: number,
limit?: number,
client?: DatabaseClient,
): Promise<Invoice[]>
}
export interface IUserRepository {
findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise<User | undefined>
upsert(user: Partial<User>, client?: DatabaseClient): Promise<number>
getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise<bigint>
}

18
src/@types/services.ts Normal file
View File

@ -0,0 +1,18 @@
import { Invoice } from './invoice'
import { Pubkey } from './base'
export interface IPaymentsService {
getInvoiceFromPaymentsProcessor(invoiceId: string): Promise<Invoice>
createInvoice(
pubkey: Pubkey,
amount: bigint,
description: string,
): Promise<Invoice>
updateInvoice(invoice: Invoice): Promise<void>
confirmInvoice(
invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt'>,
): Promise<void>
sendNewInvoiceNotification(invoice: Invoice): Promise<void>
sendInvoiceUpdateNotification(invoice: Invoice): Promise<void>
getPendingInvoices(): Promise<Invoice[]>
}

View File

@ -3,11 +3,11 @@ import { MessageType } from './messages'
import { Pubkey } from './base'
export interface Info {
relay_url?: string
name?: string
description?: string
pubkey?: string
contact?: string
relay_url: string
name: string
description: string
pubkey: string
contact: string
}
export interface Network {
@ -26,7 +26,7 @@ export interface EventIdLimits {
}
export interface PubkeyLimits {
minBalanceMsats: number
minBalance: bigint
minLeadingZeroBits: number
whitelist?: Pubkey[]
blacklist?: Pubkey[]
@ -99,6 +99,7 @@ export interface MessageLimits {
export interface ConnectionLimits {
rateLimits: RateLimit[]
ipWhitelist?: string[]
ipBlacklist?: string[]
}
export interface InvoiceLimits {
@ -125,7 +126,7 @@ export interface FeeScheduleWhitelists {
export interface FeeSchedule {
enabled: boolean
description?: string
amount: number
amount: bigint
whitelists?: FeeScheduleWhitelists
}
@ -136,23 +137,24 @@ export interface FeeSchedules {
export interface Payments {
enabled: boolean
processor: keyof PaymentProcessors
processor: keyof PaymentsProcessors
feeSchedules: FeeSchedules
}
export interface ZebedeePaymentProcessor {
export interface ZebedeePaymentsProcessor {
baseURL: string
callbackBaseURL: string
ipWhitelist: string[]
}
export interface PaymentProcessors {
zebedee?: ZebedeePaymentProcessor
export interface PaymentsProcessors {
zebedee?: ZebedeePaymentsProcessor
}
export interface ISettings {
export interface Settings {
info: Info
payments?: Payments
paymentProcessors?: PaymentProcessors
paymentProcessors?: PaymentsProcessors
network: Network
workers?: Worker
limits?: Limits

18
src/@types/user.ts Normal file
View File

@ -0,0 +1,18 @@
import { Pubkey } from './base'
export interface User {
pubkey: Pubkey
isAdmitted: boolean
balance: bigint
tosAcceptedAt?: Date | null
createdAt: Date
updatedAt: Date
}
export interface DBUser {
pubkey: Buffer
is_admitted: boolean
balance: bigint
created_at: Date
updated_at: Date
}

View File

@ -46,6 +46,24 @@ export class RedisAdapter implements ICacheAdapter {
// throw error
}
public async hasKey(key: string): Promise<boolean> {
await this.connection
debug('has %s key', key)
return Boolean(this.client.exists(key))
}
public async getKey(key: string): Promise<string> {
await this.connection
debug('get %s key', key)
return this.client.get(key)
}
public async setKey(key: string, value: string): Promise<boolean> {
await this.connection
debug('get %s key', key)
return 'OK' === await this.client.set(key, value)
}
public async removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise<number> {
await this.connection
debug('remove %d..%d range from sorted set %s', min, max, key)

View File

@ -1,13 +1,7 @@
import { Duplex, EventEmitter } from 'stream'
import { IncomingMessage, Server, ServerResponse } from 'http'
// import packageJson from '../../package.json'
import { Server } from 'http'
import { createLogger } from '../factories/logger-factory'
import { Factory } from '../@types/base'
import { getRemoteAddress } from '../utils/http'
import { IRateLimiter } from '../@types/utils'
import { ISettings } from '../@types/settings'
import { IWebServerAdapter } from '../@types/adapters'
const debug = createLogger('web-server-adapter')
@ -15,13 +9,10 @@ const debug = createLogger('web-server-adapter')
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
public constructor(
protected readonly webServer: Server,
private readonly slidingWindowRateLimiter: Factory<IRateLimiter>,
private readonly settings: () => ISettings,
) {
debug('web server starting')
super()
this.webServer
//.on('request', this.onRequest.bind(this))
.on('error', this.onError.bind(this))
.on('clientError', this.onClientError.bind(this))
.once('close', this.onClose.bind(this))
@ -37,114 +28,8 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
debug('listening for incoming connections')
}
private async onRequest(request: IncomingMessage, response: ServerResponse) {
debug('request received: %O', request.headers)
const clientAddress = getRemoteAddress(request, this.settings())
if (await this.isRateLimited(clientAddress)) {
response.end()
}
// const {
// info: { name, description, pubkey, contact },
// } = this.settings()
// try {
// if (request.method === 'GET' && request.headers['accept'] === 'application/nostr+json') {
// const relayInformationDocument = {
// name,
// description,
// pubkey,
// contact,
// supported_nips: packageJson.supportedNips,
// software: packageJson.repository.url,
// version: packageJson.version,
// }
// response.setHeader('content-type', 'application/nostr+json')
// response.setHeader('access-control-allow-origin', '*')
// const body = JSON.stringify(relayInformationDocument)
// response.end(body)
// } else if (request.headers['upgrade'] !== 'connection') {
// const url = new URL(request.url, `https://${request.headers.host}`)
// if (request.method === 'GET' && url.pathname === '/') {
// response.setHeader('content-type', 'text/html; charset=utf-8')
// response.write('<html>')
// response.write('<head>')
// response.write(`<title>${name}</title>`)
// response.write('</head>')
// response.write('<body>')
// response.write('<form action="/generate-invoice">')
// response.write('Public key (HEX): ')
// response.write('<input name="pubkey" type="text" value="" minlength="64" maxlength="64" />')
// response.write('<input type="submit" value="Request invoice" />')
// response.write('</form>')
// response.write('</body>')
// response.write('</html>')
// response.end()
// } else if (request.method === 'GET' && url.pathname === '/generate-invoice') {
// response.setHeader('content-type', 'text/html; charset=utf-8')
// response.write('<html>')
// response.write('<head>')
// response.write(`<title>${name}</title>`)
// response.write('</head>')
// response.write('<body>')
// response.write('Invoice ')
// response.write(JSON.stringify(url.searchParams))
// response.write('</body>')
// response.write('</html>')
// response.end()
// } else {
// response.setHeader('content-type', 'text/plain')
// response.end('Please use a Nostr client to connect.')
// }
// }
// } catch (error) {
// debug('error: %o', error)
// response.statusCode = 500
// response.end('Internal server error')
// }
}
private async isRateLimited(client: string): Promise<boolean> {
const {
rateLimits,
ipWhitelist = [],
} = this.settings().limits?.connection ?? {}
if (ipWhitelist.includes(client)) {
return false
}
const rateLimiter = this.slidingWindowRateLimiter()
const hit = (period: number, rate: number) =>
rateLimiter.hit(
`${client}:connection:${period}`,
1,
{ period: period, rate: rate },
)
let limited = false
for (const { rate, period } of rateLimits) {
const isRateLimited = await hit(period, rate)
if (isRateLimited) {
debug('rate limited %s: %d messages / %d ms exceeded', client, rate, period)
limited = true
}
}
return limited
}
private onError(error: Error) {
debug('error: %o', error)
throw error
}
private onClientError(error: Error, socket: Duplex) {

View File

@ -3,6 +3,7 @@ import { EventEmitter } from 'stream'
import { IncomingMessage as IncomingHttpMessage } from 'http'
import { WebSocket } from 'ws'
import { ContextMetadata, Factory } from '../@types/base'
import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { IncomingMessage, OutgoingMessage } from '../@types/messages'
@ -10,14 +11,15 @@ import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
import { attemptValidation } from '../utils/validation'
import { ContextMetadataKey } from '../constants/base'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { getRemoteAddress } from '../utils/http'
import { IRateLimiter } from '../@types/utils'
import { ISettings } from '../@types/settings'
import { isEventMatchingFilter } from '../utils/event'
import { messageSchema } from '../schemas/message-schema'
import { Settings } from '../@types/settings'
import { SocketAddress } from 'net'
const debug = createLogger('web-socket-adapter')
const debugHeartbeat = debug.extend('heartbeat')
@ -26,7 +28,7 @@ const abortableMessageHandlers: WeakMap<WebSocket, IAbortable[]> = new WeakMap()
export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter {
public clientId: string
private clientAddress: string
private clientAddress: SocketAddress
private alive: boolean
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
@ -36,7 +38,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private readonly webSocketServer: IWebSocketServerAdapter,
private readonly createMessageHandler: Factory<IMessageHandler, [IncomingMessage, IWebSocketAdapter]>,
private readonly slidingWindowRateLimiter: Factory<IRateLimiter>,
private readonly settings: Factory<ISettings>,
private readonly settings: Factory<Settings>,
) {
super()
this.alive = true
@ -44,7 +46,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex')
this.clientAddress = getRemoteAddress(this.request, this.settings())
const address = getRemoteAddress(this.request, this.settings())
this.clientAddress = new SocketAddress({
address: address,
family: address.indexOf(':') >= 0 ? 'ipv6' : 'ipv4',
})
this.client
.on('message', this.onClientMessage.bind(this))
@ -66,7 +73,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
.on(WebSocketAdapterEvent.Broadcast, this.onBroadcast.bind(this))
.on(WebSocketAdapterEvent.Message, this.sendMessage.bind(this))
debug('client %s connected from %s', this.clientId, this.clientAddress)
debug('client %s connected from %s', this.clientId, this.clientAddress.address)
}
public getClientId(): string {
@ -74,7 +81,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
}
public getClientAddress(): string {
return this.clientAddress
return this.clientAddress.address
}
public onUnsubscribed(subscriptionId: string): void {
@ -138,13 +145,17 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
let abortable = false
let messageHandler: IMessageHandler & IAbortable | undefined = undefined
try {
if (await this.isRateLimited(this.clientAddress)) {
if (await this.isRateLimited(this.clientAddress.address)) {
this.sendMessage(createNoticeMessage('rate limited'))
return
}
const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8')))
message[ContextMetadataKey] = {
remoteAddress: this.clientAddress,
} as ContextMetadata
messageHandler = this.createMessageHandler([message, this]) as IMessageHandler & IAbortable
if (!messageHandler) {
debug('unhandled message: no handler found: %o', message)

View File

@ -1,14 +1,15 @@
import { IncomingMessage, Server } from 'http'
import WebSocket, { OPEN, WebSocketServer } from 'ws'
import { propEq } from 'ramda'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { IRateLimiter } from '../@types/utils'
import { ISettings } from '../@types/settings'
import { propEq } from 'ramda'
import { getRemoteAddress } from '../utils/http'
import { isRateLimited } from '../handlers/request-handlers/rate-limiter-middleware'
import { Settings } from '../@types/settings'
import { WebServerAdapter } from './web-server-adapter'
const debug = createLogger('web-socket-server-adapter')
@ -27,10 +28,9 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
IWebSocketAdapter,
[WebSocket, IncomingMessage, IWebSocketServerAdapter]
>,
slidingWindowRateLimiter: Factory<IRateLimiter>,
settings: () => ISettings,
private readonly settings: () => Settings,
) {
super(webServer, slidingWindowRateLimiter, settings)
super(webServer)
this.webSocketsAdapters = new WeakMap()
@ -42,7 +42,6 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
.on(WebSocketServerAdapterEvent.Connection, this.onConnection.bind(this))
.on('error', (error) => {
debug('error: %o', error)
throw error
})
this.heartbeatInterval = setInterval(this.onHeartbeat.bind(this), WSS_CLIENT_HEALTH_PROBE_INTERVAL)
}
@ -68,8 +67,18 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
return Array.from(this.webSocketServer.clients).filter(propEq('readyState', OPEN)).length
}
private onConnection(client: WebSocket, req: IncomingMessage) {
debug('client connected: %o', req.headers)
private async onConnection(client: WebSocket, req: IncomingMessage) {
const currentSettings = this.settings()
const remoteAddress = getRemoteAddress(req, currentSettings)
debug('client %s connected: %o', remoteAddress, req.headers)
if (await isRateLimited(remoteAddress, currentSettings)) {
debug('client %s terminated: rate-limited', remoteAddress)
client.terminate()
return
}
this.webSocketsAdapters.set(client, this.createWebSocketAdapter([client, req, this]))
}

View File

@ -1,20 +1,25 @@
import { Cluster, Worker } from 'cluster'
import { cpus, hostname } from 'os'
import { path, pathEq } from 'ramda'
import { FSWatcher } from 'fs'
import { addOnion } from '../tor/client'
import { createLogger } from '../factories/logger-factory'
import { IRunnable } from '../@types/base'
import { ISettings } from '../@types/settings'
import packageJson from '../../package.json'
import { Serializable } from 'child_process'
import { Settings } from '../@types/settings'
import { SettingsStatic } from '../utils/settings'
const debug = createLogger('app-primary')
export class App implements IRunnable {
private watchers: FSWatcher[] | undefined
public constructor(
private readonly process: NodeJS.Process,
private readonly cluster: Cluster,
private readonly settingsFactory: () => ISettings,
private readonly settings: () => Settings,
) {
debug('starting')
@ -29,6 +34,8 @@ export class App implements IRunnable {
}
public run(): void {
this.watchers = SettingsStatic.watchSettings()
const settings = this.settings()
console.log(`
@ -44,36 +51,44 @@ export class App implements IRunnable {
const port = process.env.RELAY_PORT ? Number(process.env.RELAY_PORT) : 8008
const logCentered = (input: string, width: number) => {
const start = (width >> 1) - (input.length >> 1)
const start = (width - input.length) >> 1
console.log(' '.repeat(start), input)
}
logCentered(`v${packageJson.version} by Cameri`, width)
logCentered(`v${packageJson.version}`, width)
logCentered(`NIPs implemented: ${packageJson.supportedNips}`, width)
logCentered(`Pay-to-relay ${pathEq(['payments', 'enabled'], true, settings) ? 'enabled' : 'disabled'}`, width)
logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`, width)
const workerCount = process.env.WORKER_COUNT
? Number(process.env.WORKER_COUNT)
: this.settingsFactory().workers?.count || cpus().length
: this.settings().workers?.count || cpus().length
for (let i = 0; i < workerCount; i++) {
debug('starting worker')
this.cluster.fork()
this.cluster.fork({
WORKER_TYPE: 'worker',
})
}
this.cluster.fork({
WORKER_TYPE: 'maintenance',
})
logCentered(`${workerCount} workers started`, width)
debug('settings: %O', this.settingsFactory())
debug('settings: %O', settings)
const host = `${hostname()}:${port}`
addOnion(torHiddenServicePort, host).then(value=>{
console.info(`tor hidden service address: ${value}:${torHiddenServicePort}`)
}, (error) => {
console.error('Unable to add Tor hidden service. Skipping.', error)
}, () => {
console.error('Unable to add Tor hidden service. Skipping.')
})
}
private onClusterMessage(source: Worker, message: Serializable) {
debug('message received from worker %s: %o', source.process.pid, message)
for (const worker of Object.values(this.cluster.workers)) {
for (const worker of Object.values(this.cluster.workers as any) as Worker[]) {
if (source.id === worker.id) {
continue
}
@ -95,6 +110,20 @@ export class App implements IRunnable {
private onExit() {
console.log('exiting')
this.process.exit(0)
this.close(() => {
this.process.exit(0)
})
}
public close(callback?: (...args: any[]) => void): void {
console.log('close')
if (Array.isArray(this.watchers)) {
for (const watcher of this.watchers) {
watcher.close()
}
}
if (typeof callback === 'function') {
callback()
}
}
}

View File

@ -0,0 +1,97 @@
import { IRunnable } from '../@types/base'
import { path } from 'ramda'
import { createLogger } from '../factories/logger-factory'
import { delayMs } from '../utils/misc'
import { InvoiceStatus } from '../@types/invoice'
import { IPaymentsService } from '../@types/services'
import { Settings } from '../@types/settings'
const UPDATE_INVOICE_INTERVAL = 60000
const debug = createLogger('maintenance-worker')
export class MaintenanceWorker implements IRunnable {
private interval: NodeJS.Timer | undefined
public constructor(
private readonly process: NodeJS.Process,
private readonly paymentsService: IPaymentsService,
private readonly settings: () => Settings,
) {
this.process
.on('SIGINT', this.onExit.bind(this))
.on('SIGHUP', this.onExit.bind(this))
.on('SIGTERM', this.onExit.bind(this))
.on('uncaughtException', this.onError.bind(this))
.on('unhandledRejection', this.onError.bind(this))
}
public run(): void {
this.interval = setInterval(() => this.onSchedule(), UPDATE_INVOICE_INTERVAL)
}
private async onSchedule(): Promise<void> {
const currentSettings = this.settings()
if (!path(['payments','enabled'], currentSettings)) {
return
}
const invoices = await this.paymentsService.getPendingInvoices()
debug('found %d pending invoices', invoices.length)
const delay = () => delayMs(100 + Math.floor(Math.random() * 10))
let successful = 0
for (const invoice of invoices) {
debug('invoice %s: %o', invoice.id, invoice)
try {
debug('getting invoice %s from payment processor', invoice.id)
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice.id)
await delay()
debug('updating invoice %s: %o', invoice.id, invoice)
await this.paymentsService.updateInvoice(updatedInvoice)
if (
invoice.status !== updatedInvoice.status
&& updatedInvoice.status == InvoiceStatus.COMPLETED
&& invoice.confirmedAt
) {
debug('confirming invoice %s & notifying %s', invoice.id, invoice.pubkey)
await Promise.all([
this.paymentsService.confirmInvoice(invoice),
this.paymentsService.sendInvoiceUpdateNotification(invoice),
])
await delay()
}
successful++
} catch (error) {
console.error('Unable to update invoice from payment processor. Reason:', error)
}
debug('updated %d of %d invoices successfully', successful, invoices.length)
}
}
private onError(error: Error) {
debug('error: %o', error)
throw error
}
private onExit() {
debug('exiting')
this.close(() => {
this.process.exit(0)
})
}
public close(callback?: () => void) {
debug('closing')
clearInterval(this.interval)
if (typeof callback === 'function') {
callback()
}
}
}

View File

@ -2,9 +2,13 @@ import { IRunnable } from '../@types/base'
import { IWebSocketServerAdapter } from '../@types/adapters'
import { createLogger } from '../factories/logger-factory'
import { FSWatcher } from 'fs'
import { SettingsStatic } from '../utils/settings'
const debug = createLogger('app-worker')
export class AppWorker implements IRunnable {
private watchers: FSWatcher[] | undefined
public constructor(
private readonly process: NodeJS.Process,
private readonly adapter: IWebSocketServerAdapter
@ -19,6 +23,8 @@ export class AppWorker implements IRunnable {
}
public run(): void {
this.watchers = SettingsStatic.watchSettings()
const port = process.env.PORT || process.env.RELAY_PORT || 8008
this.adapter.listen(typeof port === 'number' ? port : Number(port))
}
@ -46,6 +52,13 @@ export class AppWorker implements IRunnable {
public close(callback?: () => void) {
debug('closing')
this.adapter.close(callback as () => void)
if (Array.isArray(this.watchers)) {
for (const watcher of this.watchers) {
watcher.close()
}
}
if (typeof callback !== 'undefined') {
this.adapter.close(callback)
}
}
}

View File

@ -44,3 +44,4 @@ export enum PaymentsProcessors {
export const EventDelegatorMetadataKey = Symbol('Delegator')
export const EventDeduplicationMetadataKey = Symbol('Deduplication')
export const ContextMetadataKey = Symbol('Context')

View File

@ -0,0 +1,4 @@
export enum FeeSchedules {
ADMISSION = 'admission',
PUBLICATION = 'publication'
}

View File

@ -1,23 +1,16 @@
import { andThen, pipe } from 'ramda'
import { Request, Response } from 'express'
import cluster from 'cluster'
import { Event, UnidentifiedEvent } from '../../@types/event'
import { EventKinds, PaymentsProcessors } from '../../constants/base'
import { getPrivateKeyFromSecret, getPublicKey, identifyEvent, signEvent } from '../../utils/event'
import { IEventRepository, IInvoiceRepository } from '../../@types/repositories'
import { InvoiceStatus, InvoiceUnit } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { fromZebedeeInvoice } from '../../utils/transform'
import { IController } from '../../@types/controllers'
import { WebSocketServerAdapterEvent } from '../../constants/adapter'
import { InvoiceStatus } from '../../@types/invoice'
import { IPaymentsService } from '../../@types/services'
const debug = createLogger('zebedee-callback-controller')
export class ZebedeeCallbackController implements IController {
public constructor(
private readonly invoiceRepository: IInvoiceRepository,
private readonly eventRepository: IEventRepository,
private readonly paymentsService: IPaymentsService,
) {}
// TODO: Validate
@ -25,19 +18,25 @@ export class ZebedeeCallbackController implements IController {
request: Request,
response: Response,
) {
debug('request headers: %o', request.headers)
debug('request body: %o', request.body)
const invoice = fromZebedeeInvoice(request.body)
debug('invoice', invoice)
try {
await this.invoiceRepository.upsert(invoice)
await this.paymentsService.updateInvoice(invoice)
} catch (error) {
console.error('Unable to persist invoice:', invoice.bolt11)
console.error(`Unable to persist invoice ${invoice.id}`, error)
throw error
}
if (invoice.status !== InvoiceStatus.COMPLETED) {
if (
invoice.status !== InvoiceStatus.COMPLETED
&& !invoice.confirmedAt
) {
response
.status(200)
.send()
@ -45,37 +44,15 @@ export class ZebedeeCallbackController implements IController {
return
}
// Generate deterministic private key for given pubkey
const privkey = getPrivateKeyFromSecret(process.env.SECRET)(invoice.pubkey)
const pubkey = getPublicKey(privkey)
const amountPaid = (invoice.unit === InvoiceUnit.MSATS) ? invoice.amountPaid / 1000n : invoice.amountPaid
const newEvent: UnidentifiedEvent = {
pubkey,
kind: EventKinds.INVOICE_UPDATE,
created_at: Math.floor(invoice.confirmedAt.getTime() / 1000),
content: `${amountPaid.toString()} ${(invoice.unit === InvoiceUnit.BTC) ? 'BTC' : 'sats'} received`,
tags: [
['p', invoice.pubkey],
['status', invoice.status],
['payments-processor', PaymentsProcessors.ZEBEDEE],
['r', `lightning:${invoice.bolt11}`],
],
}
const event: Event = await pipe(
identifyEvent,
andThen(signEvent(privkey)),
)(newEvent)
invoice.amountPaid = invoice.amountRequested
try {
await this.eventRepository.create(event)
await this.paymentsService.confirmInvoice(invoice)
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
} catch (error) {
response.status(500).send(`Unable to save event for invoice: ${invoice.bolt11}`)
return
} finally {
this.broadcastEvent(event)
console.error(`Unable to confirm invoice ${invoice.id}`, error)
throw error
}
response
@ -83,13 +60,4 @@ export class ZebedeeCallbackController implements IController {
.setHeader('content-type', 'text/plain; charset=utf8')
.send('OK')
}
private broadcastEvent(event: Event) {
if (cluster.isWorker) {
process.send({
eventName: WebSocketServerAdapterEvent.Broadcast,
event,
})
}
}
}

View File

@ -0,0 +1,206 @@
import { Request, Response } from 'express'
import { path } from 'ramda'
import { readFileSync } from 'fs'
import { FeeSchedule, Settings } from '../../@types/settings'
import { fromBech32, toBech32 } from '../../utils/transform'
import { getPrivateKeyFromSecret, getPublicKey } from '../../utils/event'
import { createLogger } from '../../factories/logger-factory'
import { getRemoteAddress } from '../../utils/http'
import { IController } from '../../@types/controllers'
import { Invoice } from '../../@types/invoice'
import { IPaymentsService } from '../../@types/services'
import { IRateLimiter } from '../../@types/utils'
import { IUserRepository } from '../../@types/repositories'
let pageCache: string
const debug = createLogger('post-invoice-controller')
export class PostInvoiceController implements IController {
public constructor(
private readonly userRepository: IUserRepository,
private readonly paymentsService: IPaymentsService,
private readonly settings: () => Settings,
private readonly rateLimiter: () => IRateLimiter,
){}
public async handleRequest(request: Request, response: Response): Promise<void> {
if (!pageCache) {
pageCache = readFileSync('./resources/invoices.html', 'utf8')
}
debug('params: %o', request.params)
debug('body: %o', request.body)
const currentSettings = this.settings()
const {
info: { name: relayName, relay_url: relayUrl },
} = currentSettings
const limited = await this.isRateLimited(request, currentSettings)
if (limited) {
response
.status(429)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Too many requests')
return
}
if (!request.body || typeof request.body !== 'object') {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid request')
return
}
const tosAccepted = request.body?.tosAccepted === 'yes'
if (!tosAccepted) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('ToS agreement: not accepted')
return
}
const isAdmissionInvoice = request.body?.feeSchedule === 'admission'
if (!isAdmissionInvoice) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid fee')
return
}
const pubkeyRaw = path(['body', 'pubkey'], request)
let pubkey: string
if (typeof pubkeyRaw !== 'string') {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid pubkey: missing')
return
} else if (/^[0-9a-f]{64}$/.test(pubkeyRaw)) {
pubkey = pubkeyRaw
} else if (/^npub1/.test(pubkeyRaw)) {
try {
pubkey = fromBech32(pubkeyRaw)
} catch (error) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid pubkey: invalid npub')
return
}
} else {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid pubkey: unknown format')
return
}
const isApplicableFee = (feeSchedule: FeeSchedule) => feeSchedule.enabled
&& !feeSchedule.whitelists?.pubkeys?.some((prefix) => pubkey.startsWith(prefix))
const admissionFee = currentSettings.payments?.feeSchedules.admission
.filter(isApplicableFee)
if (!Array.isArray(admissionFee) || !admissionFee.length) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('No admission fee required')
return
}
const minBalance = currentSettings.limits?.event?.pubkey?.minBalance ?? 0n
const user = await this.userRepository.findByPubkey(pubkey)
if (user && user.isAdmitted && minBalance > 0n && user.balance >= minBalance) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('User is already admitted.')
return
}
let invoice: Invoice
const amount = admissionFee.reduce((sum, fee) => sum + BigInt(fee.amount), 0n)
try {
const description = `${relayName} Admission Fee for ${toBech32('npub')(pubkey)}`
invoice = await this.paymentsService.createInvoice(
pubkey,
amount,
description,
)
await this.paymentsService.sendNewInvoiceNotification(invoice)
} catch (error) {
console.error('Unable to create invoice. Reason:', error)
response
.status(500)
.setHeader('content-type', 'text/plain')
.send('Unable to create invoice')
return
}
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
const relayPubkey = getPublicKey(relayPrivkey)
const replacements = {
name: relayName,
reference: invoice.id,
relay_url: relayUrl,
pubkey,
relay_pubkey: relayPubkey,
expires_at: invoice.expiresAt?.toISOString(),
invoice: invoice.bolt11,
amount: amount / 1000n,
}
const body = Object
.entries(replacements)
.reduce((body, [key, value]) => body.replaceAll(`{{${key}}}`, value.toString()), pageCache)
response
.status(200)
.setHeader('Content-Type', 'text/html; charset=utf8')
.send(body)
return
}
public async isRateLimited(request: Request, settings: Settings) {
const rateLimits = path(['limits', 'invoice', 'rateLimits'], settings)
if (!Array.isArray(rateLimits) || !rateLimits.length) {
return false
}
const ipWhitelist = path(['limits', 'invoice', 'ipWhitelist'], settings)
const remoteAddress = getRemoteAddress(request, settings)
let limited = false
if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) {
const rateLimiter = this.rateLimiter()
for (const { rate, period } of rateLimits) {
if (await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })) {
debug('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
limited = true
}
}
}
return limited
}
}

View File

@ -0,0 +1,37 @@
import { Knex } from 'knex'
import { DatabaseClient, DatabaseTransaction } from '../@types/base'
import { ITransaction } from '../@types/database'
export class Transaction implements ITransaction {
private trx: Knex.Transaction<any, any[]>
public constructor(
private readonly dbClient: DatabaseClient,
) {}
public async begin(): Promise<void> {
this.trx = await this.dbClient.transaction(null, { isolationLevel: 'serializable' })
}
public get transaction (): DatabaseTransaction {
if (!this.trx) {
throw new Error('Unable to get transaction: transaction not started.')
}
return this.trx
}
public async commit(): Promise<any[]> {
if (!this.trx) {
throw new Error('Unable to get transaction: transaction not started.')
}
return this.trx.commit()
}
public async rollback(): Promise<any[]> {
if (!this.trx) {
throw new Error('Unable to get transaction: transaction not started.')
}
return this.trx.rollback()
}
}

View File

@ -5,7 +5,7 @@ export const createLogger = (
namespace: string,
options: { enabled?: boolean; stdout?: boolean } = { enabled: false, stdout: false }
) => {
const prefix = cluster.isWorker ? 'worker' : 'primary'
const prefix = cluster.isWorker ? process.env.WORKER_TYPE : 'primary'
const instance = debug(prefix)
if (options.enabled) {
debug.enable(`${prefix}:${namespace}:*`)

View File

@ -0,0 +1,27 @@
import { createPaymentsProcessor } from './payments-processor-factory'
import { createSettings } from './settings-factory'
import { EventRepository } from '../repositories/event-repository'
import { getDbClient } from '../database/client'
import { InvoiceRepository } from '../repositories/invoice-repository'
import { MaintenanceWorker } from '../app/maintenance-worker'
import { PaymentsService } from '../services/payments-service'
import { UserRepository } from '../repositories/user-repository'
export const maintenanceWorkerFactory = () => {
const dbClient = getDbClient()
const paymentsProcessor = createPaymentsProcessor()
const userRepository = new UserRepository(dbClient)
const invoiceRepository = new InvoiceRepository(dbClient)
const eventRepository = new EventRepository(dbClient)
const paymentsService = new PaymentsService(
dbClient,
paymentsProcessor,
userRepository,
invoiceRepository,
eventRepository,
createSettings,
)
return new MaintenanceWorker(process, paymentsService, createSettings)
}

View File

@ -1,10 +1,10 @@
import { IEventRepository, IUserRepository } from '../@types/repositories'
import { IncomingMessage, MessageType } from '../@types/messages'
import { createSettings } from './settings-factory'
import { DelegatedEventMessageHandler } from '../handlers/delegated-event-message-handler'
import { delegatedEventStrategyFactory } from './delegated-event-strategy-factory'
import { EventMessageHandler } from '../handlers/event-message-handler'
import { eventStrategyFactory } from './event-strategy-factory'
import { IEventRepository } from '../@types/repositories'
import { isDelegatedEvent } from '../utils/event'
import { IWebSocketAdapter } from '../@types/adapters'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
@ -13,6 +13,7 @@ import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handl
export const messageHandlerFactory = (
eventRepository: IEventRepository,
userRepository: IUserRepository,
) => ([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => {
switch (message[0]) {
case MessageType.EVENT:
@ -21,6 +22,7 @@ export const messageHandlerFactory = (
return new DelegatedEventMessageHandler(
adapter,
delegatedEventStrategyFactory(eventRepository),
userRepository,
createSettings,
slidingWindowRateLimiterFactory,
)
@ -29,6 +31,7 @@ export const messageHandlerFactory = (
return new EventMessageHandler(
adapter,
eventStrategyFactory(eventRepository),
userRepository,
createSettings,
slidingWindowRateLimiterFactory,
)

View File

@ -1,35 +1,64 @@
import axios from 'axios'
import axios, { CreateAxiosDefaults } from 'axios'
import { path } from 'ramda'
import { createLogger } from './logger-factory'
import { createSettings } from './settings-factory'
import { ISettings } from '../@types/settings'
import { IPaymentsProcessor } from '../@types/clients'
import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor'
import { PaymentsProcessor } from '../payments-processors/payments-procesor'
import { Settings } from '../@types/settings'
import { ZebedeePaymentsProcesor } from '../payments-processors/zebedee-payments-processor'
const createZebedeePaymentsProcessor = (settings: ISettings) => {
const client = axios.create({
const debug = createLogger('create-zebedee-payments-processor')
const getConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.ZEBEDEE_API_KEY) {
throw new Error('ZEBEDEE_API_KEY must be set.')
}
return {
headers: {
'content-type': 'application/json',
'apikey': process.env.ZEBEDEE_API_KEY,
},
baseURL: settings.paymentProcessors.zebedee.baseURL,
baseURL: path(['paymentsProcessors', 'zebedee', 'baseURL'], settings),
maxRedirects: 1,
})
}
}
const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'zebedee', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
throw new Error('Unable to create payments processor: Setting paymentsProcessor.zebedee.callbackBaseURL is not configured.')
}
if (
!Array.isArray(settings.paymentsProcessors?.zebedee?.ipWhitelist)
|| !settings.paymentsProcessors?.zebedee?.ipWhitelist?.length
) {
throw new Error('Unable to create payments processor: Setting paymentsProcessor.zebedee.ipWhitelist is empty.')
}
const config = getConfig(settings)
debug('config: %o', config)
const client = axios.create(config)
const zpp = new ZebedeePaymentsProcesor(client, createSettings)
return new PaymentsProcessor(zpp)
}
export const createPaymentsProcessor = () => {
export const createPaymentsProcessor = (): IPaymentsProcessor => {
const settings = createSettings()
if (!settings.payments.enabled) {
throw new Error('Payments disabled')
if (!settings.payments?.enabled) {
throw new Error('Unable to create payments processor: Setting payments.enabled is false')
}
switch (settings.payments.processor) {
switch (settings.payments?.processor) {
case 'zebedee':
return createZebedeePaymentsProcessor(settings)
default:
return new NullPaymentsProcessor()
}
}
}

View File

@ -0,0 +1,24 @@
import { createPaymentsProcessor } from './payments-processor-factory'
import { createSettings } from './settings-factory'
import { EventRepository } from '../repositories/event-repository'
import { getDbClient } from '../database/client'
import { InvoiceRepository } from '../repositories/invoice-repository'
import { PaymentsService } from '../services/payments-service'
import { UserRepository } from '../repositories/user-repository'
export const createPaymentsService = () => {
const dbClient = getDbClient()
const invoiceRepository = new InvoiceRepository(dbClient)
const userRepository = new UserRepository(dbClient)
const paymentsProcessor = createPaymentsProcessor()
const eventRepository = new EventRepository(dbClient)
return new PaymentsService(
dbClient,
paymentsProcessor,
userRepository,
invoiceRepository,
eventRepository,
createSettings
)
}

View File

@ -0,0 +1,33 @@
import { createPaymentsProcessor } from './payments-processor-factory'
import { createSettings } from './settings-factory'
import { EventRepository } from '../repositories/event-repository'
import { getDbClient } from '../database/client'
import { IController } from '../@types/controllers'
import { InvoiceRepository } from '../repositories/invoice-repository'
import { PaymentsService } from '../services/payments-service'
import { PostInvoiceController } from '../controllers/invoices/post-invoice-controller'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
import { UserRepository } from '../repositories/user-repository'
export const createPostInvoiceController = (): IController => {
const dbClient = getDbClient()
const eventRepository = new EventRepository(dbClient)
const invoiceRepository = new InvoiceRepository(dbClient)
const userRepository = new UserRepository(dbClient)
const paymentsProcessor = createPaymentsProcessor()
const paymentsService = new PaymentsService(
dbClient,
paymentsProcessor,
userRepository,
invoiceRepository,
eventRepository,
createSettings,
)
return new PostInvoiceController(
userRepository,
paymentsService,
createSettings,
slidingWindowRateLimiterFactory,
)
}

View File

@ -1,4 +1,4 @@
import { ISettings } from '../@types/settings'
import { Settings } from '../@types/settings'
import { SettingsStatic } from '../utils/settings'
export const createSettings = (): ISettings => SettingsStatic.createSettings()
export const createSettings = (): Settings => SettingsStatic.createSettings()

View File

@ -1,8 +1,8 @@
import { IncomingMessage } from 'http'
import { WebSocket } from 'ws'
import { IEventRepository, IUserRepository } from '../@types/repositories'
import { createSettings } from './settings-factory'
import { IEventRepository } from '../@types/repositories'
import { IWebSocketServerAdapter } from '../@types/adapters'
import { messageHandlerFactory } from './message-handler-factory'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
@ -11,12 +11,13 @@ import { WebSocketAdapter } from '../adapters/web-socket-adapter'
export const webSocketAdapterFactory = (
eventRepository: IEventRepository,
userRepository: IUserRepository,
) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) =>
new WebSocketAdapter(
client,
request,
webSocketServerAdapter,
messageHandlerFactory(eventRepository),
messageHandlerFactory(eventRepository, userRepository),
slidingWindowRateLimiterFactory,
createSettings,
)

View File

@ -1,3 +1,4 @@
import { is, path, pathSatisfies } from 'ramda'
import express from 'express'
import helmet from 'helmet'
import http from 'http'
@ -8,8 +9,9 @@ import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
import { AppWorker } from '../app/worker'
import { createSettings } from '../factories/settings-factory'
import { EventRepository } from '../repositories/event-repository'
import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware'
import router from '../routes'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
import { UserRepository } from '../repositories/user-repository'
import { webSocketAdapterFactory } from './websocket-adapter-factory'
import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter'
@ -17,15 +19,21 @@ export const workerFactory = (): AppWorker => {
const dbClient = getMasterDbClient()
const readReplicaDbClient = getReadReplicaDbClient()
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
const userRepository = new UserRepository(dbClient)
const settings = createSettings()
const app = express()
app
.disable('x-powered-by')
.use( helmet.contentSecurityPolicy({
.use(rateLimiterMiddleware)
.use(helmet.contentSecurityPolicy({
directives: {
/**
* TODO: Remove 'unsafe-inline'
*/
'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/'],
@ -39,15 +47,13 @@ export const workerFactory = (): AppWorker => {
// deepcode ignore HttpToHttps: we use proxies
const server = http.createServer(app)
const settings = createSettings()
let maxPayloadSize: number | undefined
if (settings.network['max_payload_size']) {
if (pathSatisfies(is(String), ['network', 'max_payload_size'], settings)) {
console.warn(`WARNING: Setting network.max_payload_size is deprecated and will be removed in a future version.
Use network.maxPayloadSize instead.`)
maxPayloadSize = settings.network['max_payload_size']
maxPayloadSize = path(['network', 'max_payload_size'], settings)
} else {
maxPayloadSize = settings.network.maxPayloadSize
maxPayloadSize = path(['network', 'maxPayloadSize'], settings)
}
const webSocketServer = new WebSocketServer({
@ -57,8 +63,7 @@ export const workerFactory = (): AppWorker => {
const adapter = new WebSocketServerAdapter(
server,
webSocketServer,
webSocketAdapterFactory(eventRepository),
slidingWindowRateLimiterFactory,
webSocketAdapterFactory(eventRepository, userRepository),
createSettings,
)

View File

@ -1,16 +1,29 @@
import { createPaymentsProcessor } from './payments-processor-factory'
import { createSettings } from './settings-factory'
import { EventRepository } from '../repositories/event-repository'
import { getDbClient } from '../database/client'
import { IController } from '../@types/controllers'
import { InvoiceRepository } from '../repositories/invoice-repository'
import { PaymentsService } from '../services/payments-service'
import { UserRepository } from '../repositories/user-repository'
import { ZebedeeCallbackController } from '../controllers/callbacks/zebedee-callback-controller'
export const createZebedeeCallbackController = (): IController => {
const dbClient = getDbClient()
const eventRepository = new EventRepository(dbClient)
const invoiceRepotistory = new InvoiceRepository(dbClient)
return new ZebedeeCallbackController(
const userRepository = new UserRepository(dbClient)
const paymentsProcessor = createPaymentsProcessor()
const paymentsService = new PaymentsService(
dbClient,
paymentsProcessor,
userRepository,
invoiceRepotistory,
eventRepository,
createSettings,
)
return new ZebedeeCallbackController(
paymentsService,
)
}

View File

@ -34,6 +34,13 @@ export class DelegatedEventMessageHandler extends EventMessageHandler implements
return
}
reason = await this.isUserAdmitted(event)
if (reason) {
debug('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason))
return
}
const [, delegator] = event.tags.find((tag) => tag.length === 4 && tag[0] === EventTags.Delegation)
const delegatedEvent: DelegatedEvent = {
...event,

View File

@ -1,12 +1,14 @@
import { EventRateLimit, ISettings } from '../@types/settings'
import { EventRateLimit, FeeSchedule, Settings } from '../@types/settings'
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid } from '../utils/event'
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
import { ContextMetadataKey } from '../constants/base'
import { createCommandResult } from '../utils/messages'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { IncomingEventMessage } from '../@types/messages'
import { IRateLimiter } from '../@types/utils'
import { IUserRepository } from '../@types/repositories'
import { IWebSocketAdapter } from '../@types/adapters'
import { WebSocketAdapterEvent } from '../constants/adapter'
@ -16,13 +18,16 @@ export class EventMessageHandler implements IMessageHandler {
public constructor(
protected readonly webSocket: IWebSocketAdapter,
protected readonly strategyFactory: Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]>,
private readonly settings: () => ISettings,
protected readonly userRepository: IUserRepository,
private readonly settings: () => Settings,
private readonly slidingWindowRateLimiter: Factory<IRateLimiter>,
) {}
public async handleMessage(message: IncomingEventMessage): Promise<void> {
const [, event] = message
event[ContextMetadataKey] = message[ContextMetadataKey]
let reason = await this.isEventValid(event)
if (reason) {
debug('event %s rejected: %s', event.id, reason)
@ -43,6 +48,13 @@ export class EventMessageHandler implements IMessageHandler {
return
}
reason = await this.isUserAdmitted(event)
if (reason) {
debug('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason))
return
}
const strategy = this.strategyFactory([event, this.webSocket])
if (typeof strategy?.execute !== 'function') {
@ -221,4 +233,32 @@ export class EventMessageHandler implements IMessageHandler {
return limited
}
protected async isUserAdmitted(event: Event): Promise<string | undefined> {
const currentSettings = this.settings()
if (!currentSettings.payments?.enabled) {
return
}
const isApplicableFee = (feeSchedule: FeeSchedule) =>
feeSchedule.enabled
&& !feeSchedule.whitelists?.pubkeys?.some((prefix) => event.pubkey.startsWith(prefix))
const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee)
if (!Array.isArray(feeSchedules) || !feeSchedules.length) {
return
}
// const hasKey = await this.cache.hasKey(`${event.pubkey}:is-admitted`)
// TODO: use cache
const user = await this.userRepository.findByPubkey(event.pubkey)
if (!user || !user.isAdmitted) {
return 'blocked: pubkey not admitted'
}
const minBalance = currentSettings.limits?.event?.pubkey?.minBalance ?? 0n
if (minBalance > 0n && user.balance < minBalance) {
return 'blocked: insufficient balance'
}
}
}

View File

@ -1,17 +1,29 @@
import { NextFunction, Request, Response } from 'express'
import { path, pathEq } from 'ramda'
import { readFileSync } from 'fs'
import { createSettings as settings } from '../../factories/settings-factory'
import { createSettings } from '../../factories/settings-factory'
import { FeeSchedule } from '../../@types/settings'
let pageCache: string
export const getInvoiceRequestHandler = (_req: Request, res: Response, next: NextFunction) => {
const { info: { name } } = settings()
const settings = createSettings()
if (!pageCache) {
pageCache = readFileSync('./resources/index.html', 'utf8').replaceAll('{{name}}', name)
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('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
}
res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache)
} else {
res.status(404).send()
}
res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache)
next()
}

View File

@ -1,147 +1,18 @@
import { NextFunction, Request, Response } from 'express'
import { readFileSync } from 'fs'
import { Request, Response } from 'express'
import { createPostInvoiceController } from '../../factories/post-invoice-controller-factory'
import { getPrivateKeyFromSecret, getPublicKey } from '../../utils/event'
import { createLogger } from '../../factories/logger-factory'
import { fromNpub } from '../../utils/transform'
import { getRemoteAddress } from '../../utils/http'
import { IPaymentsProcessor } from '../../@types/clients'
import { createSettings as settings } from '../../factories/settings-factory'
import { slidingWindowRateLimiterFactory } from '../../factories/rate-limiter-factory'
let pageCache: string
const debug = createLogger('post-invoice-request-handler')
// deepcode ignore NoRateLimitingForExpensiveWebOperation: only read once
export const postInvoiceRequestHandler = async (
req: Request,
res: Response,
next: NextFunction,
) => {
if (!pageCache) {
pageCache = readFileSync('./resources/invoices.html', 'utf8')
}
const controller = createPostInvoiceController()
debug('params: %o', req.params)
debug('body: %o', req.body)
const currentSettings = settings()
const {
info: { name, relay_url },
limits: { invoice: { ipWhitelist, rateLimits } },
} = currentSettings
const remoteAddress = getRemoteAddress(req, currentSettings)
const isRateLimited = async (remoteAddress: string) => {
let limited = false
if (!ipWhitelist.includes(remoteAddress)) {
const rateLimiter = slidingWindowRateLimiterFactory()
for (const { rate, period } of rateLimits) {
if (await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })) {
debug('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
limited = true
}
}
}
return limited
}
const limited = await isRateLimited(remoteAddress)
if (limited) {
try {
await controller.handleRequest(req, res)
} catch (error) {
res
.status(429)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Too many requests')
return next()
.status(500)
.setHeader('content-type', 'text-plain')
.send('Error handling request')
}
const tosAccepted = req.body?.tosAccepted === 'yes'
if (!tosAccepted) {
res
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('ToS agreement: not accepted')
return next()
}
const pubkeyRaw = typeof req.body?.pubkey === 'string'
? req.body?.pubkey?.trim()
: undefined
let pubkey: string
if (typeof pubkeyRaw !== 'string') {
res
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid pubkey: missing')
return next()
} else if (/^[0-9a-f]{64}$/.test(pubkeyRaw)) {
pubkey = pubkeyRaw
} else if (/^npub/.test(pubkeyRaw)) {
try {
pubkey = fromNpub(pubkeyRaw)
} catch (error) {
res
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid pubkey: npub not valid')
return next()
}
} else {
res
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Invalid pubkey: unknown format')
return next()
}
const admissionFee = currentSettings.payments?.feeSchedules.admission
.filter((feeSchedule) => feeSchedule.enabled && !feeSchedule.whitelists.pubkeys.includes(pubkey))
if (!Array.isArray(admissionFee) || !admissionFee.length) {
res
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('No admission fee required')
return next()
}
const paymentsProcessor = req['paymentsProcessor'] as IPaymentsProcessor
const invoiceResponse = await paymentsProcessor.createInvoice({
amountMsats: admissionFee.reduce((sum, fee) => sum + fee.amount, 0),
description: `Admission Fee for ${pubkey}`,
requestId: pubkey,
})
const privkey = getPrivateKeyFromSecret(process.env.SECRET)(pubkey)
const relayPubkey = getPublicKey(privkey)
const replacements = {
name,
pubkey,
relay_url,
relay_pubkey: relayPubkey,
invoice: invoiceResponse.invoice.bolt11,
}
const body = Object
.entries(replacements)
.reduce((body, [key, value]) => body.replaceAll(`{{${key}}}`, value), pageCache)
res
.status(200)
.setHeader('Content-Type', 'text/html; charset=utf8')
.send(body)
return next()
}

View File

@ -7,5 +7,12 @@ export const postZebedeeCallbackRequestHandler = async (
) => {
const controller = createZebedeeCallbackController()
return controller.handleRequest(req, res)
try {
await controller.handleRequest(req, res)
} catch (error) {
res
.status(500)
.setHeader('content-type', 'text-plain')
.send('Error handling request')
}
}

View File

@ -0,0 +1,57 @@
import { NextFunction, Request, Response } from 'express'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { getRemoteAddress } from '../../utils/http'
import { Settings } from '../../@types/settings'
import { slidingWindowRateLimiterFactory } from '../../factories/rate-limiter-factory'
const debug = createLogger('rate-limiter-middleware')
export const rateLimiterMiddleware = async (request: Request, response: Response, next: NextFunction) => {
const currentSettings = createSettings()
const clientAddress = getRemoteAddress(request, currentSettings).split(',')[0]
debug('request received from %s: %O', clientAddress, request.headers)
if (await isRateLimited(clientAddress, currentSettings)) {
response.destroy()
return next(new Error('Rate-limited'))
}
next()
}
export async function isRateLimited(remoteAddress: string, settings: Settings): Promise<boolean> {
const {
rateLimits,
ipWhitelist = [],
} = settings.limits?.connection ?? {}
if (ipWhitelist.includes(remoteAddress)) {
return false
}
const rateLimiter = slidingWindowRateLimiterFactory()
const hit = (period: number, rate: number) =>
rateLimiter.hit(
`${remoteAddress}:connection:${period}`,
1,
{ period: period, rate: rate },
)
let limited = false
for (const { rate, period } of rateLimits) {
const isRateLimited = await hit(period, rate)
if (isRateLimited) {
debug('rate limited %s: %d messages / %d ms exceeded', remoteAddress, rate, period)
limited = true
}
}
return limited
}

View File

@ -1,6 +1,16 @@
import { NextFunction, Request, Response } from 'express'
import { path } from 'ramda'
import { createSettings } from '../../factories/settings-factory'
export const rootRequestHandler = (_req: Request, res: Response, next: NextFunction) => {
res.redirect(301, '/invoices')
const settings = createSettings()
const admissionFeeEnabled = path(['payments','feeSchedules','admission', '0', 'enabled'])(settings)
if (admissionFeeEnabled) {
res.redirect(301, '/invoices')
} else {
res.status(200).setHeader('content-type', 'text/plain; charset=utf8').send('Please use a Nostr client to connect.')
}
next()
}

View File

@ -10,8 +10,8 @@ import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { IEventRepository } from '../@types/repositories'
import { ISettings } from '../@types/settings'
import { IWebSocketAdapter } from '../@types/adapters'
import { Settings } from '../@types/settings'
import { SubscribeMessage } from '../@types/messages'
import { WebSocketAdapterEvent } from '../constants/adapter'
@ -23,7 +23,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
private readonly settings: () => ISettings,
private readonly settings: () => Settings,
) {
this.abortController = new AbortController()
}
@ -58,11 +58,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
const findEvents = this.eventRepository.findByFilters(filters).stream()
const abortableFintEvents = addAbortSignal(this.abortController.signal, findEvents)
const abortableFindEvents = addAbortSignal(this.abortController.signal, findEvents)
try {
await pipeline(
abortableFintEvents,
abortableFindEvents,
streamFilter(propSatisfies(isNil, 'deleted_at')),
streamMap(toNostrEvent),
streamFilter(isSubscribedToEvent),

View File

@ -3,15 +3,24 @@ import dotenv from 'dotenv'
dotenv.config()
import { appFactory } from './factories/app-factory'
import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory'
import { workerFactory } from './factories/worker-factory'
export const getRunner = (isPrimary: boolean) => {
return (isPrimary)
? appFactory()
: workerFactory()
export const getRunner = () => {
if (cluster.isPrimary) {
return appFactory()
} else {
switch (process.env.WORKER_TYPE) {
case 'worker':
return workerFactory()
case 'maintenance':
return maintenanceWorkerFactory()
default:
throw new Error(`Unknown worker: ${process.env.WORKER_TYPE}`)
}
}
}
if (require.main === module) {
getRunner(cluster.isPrimary).run()
getRunner().run()
}

View File

@ -1,13 +1,37 @@
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { InvoiceStatus, InvoiceUnit } from '../@types/invoice'
export class NullPaymentsProcessor implements IPaymentsProcessor {
public async getInvoice(invoiceId: string): Promise<GetInvoiceResponse> {
const date = new Date()
return {
id: invoiceId,
pubkey: '',
bolt11: '',
description: '',
status: InvoiceStatus.PENDING,
unit: InvoiceUnit.MSATS,
amountRequested: 0n,
expiresAt: date,
confirmedAt: null,
createdAt: date,
updatedAt: date,
}
}
public async createInvoice(_request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
return {
amount: 0,
externalReference: '',
invoice: {
bolt11: '',
},
description: '',
status: InvoiceStatus.PENDING,
unit: InvoiceUnit.MSATS,
amountRequested: 0n,
id: '',
expiresAt: new Date(),
bolt11: '',
pubkey: '',
rawResponse: '',
confirmedAt: null,
createdAt: new Date(),
}
}
}

View File

@ -1,10 +1,15 @@
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { Invoice } from '../@types/invoice'
export class PaymentsProcessor implements IPaymentsProcessor {
public constructor(
private readonly processor: IPaymentsProcessor
) {}
public async getInvoice(invoiceId: string): Promise<Invoice> {
return this.processor.getInvoice(invoiceId)
}
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
return this.processor.createInvoice(request)
}

View File

@ -1,24 +1,39 @@
import { applySpec, path, pipe } from 'ramda'
import { AxiosInstance } from 'axios'
import { Factory } from '../@types/base'
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { createLogger } from '../factories/logger-factory'
import { ISettings } from '../@types/settings'
import { toJSON } from '../utils/transform'
import { fromZebedeeInvoice } from '../utils/transform'
import { Settings } from '../@types/settings'
const debug = createLogger('zebedee-payments-processor')
export class ZebedeePaymentsProcesor implements IPaymentsProcessor {
public constructor(
private httpClient: AxiosInstance,
private settings: Factory<ISettings>
private settings: Factory<Settings>
) {}
public async getInvoice(invoiceId: string): Promise<GetInvoiceResponse> {
debug('get invoice: %s', invoiceId)
try {
const response = await this.httpClient.get(`/v0/charges/${invoiceId}`, {
maxRedirects: 1,
})
return fromZebedeeInvoice(response.data.data)
} catch (error) {
console.error(`Unable to get invoice ${invoiceId}. Reason:`, error)
throw error
}
}
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
debug('create invoice: %o', request)
const {
amountMsats,
amount: amountMsats,
description,
requestId,
} = request
@ -27,7 +42,7 @@ export class ZebedeePaymentsProcesor implements IPaymentsProcessor {
amount: amountMsats.toString(),
description,
internalId: requestId,
callbackUrl: this.settings().paymentProcessors?.zebedee?.callbackBaseURL,
callbackUrl: this.settings().paymentsProcessors?.zebedee?.callbackBaseURL,
}
try {
@ -36,16 +51,7 @@ export class ZebedeePaymentsProcesor implements IPaymentsProcessor {
maxRedirects: 1,
})
const result = pipe(
applySpec<CreateInvoiceResponse>({
externalReference: path(['data', 'id']),
amount: pipe(path(['data', 'amount']), Number),
invoice: applySpec({
bolt11: path(['data', 'invoice', 'request']),
}),
rawResponse: toJSON,
})
)(response.data)
const result = fromZebedeeInvoice(response.data.data)
debug('result: %o', result)

View File

@ -20,6 +20,7 @@ import {
modulo,
nth,
omit,
path,
paths,
pipe,
prop,
@ -28,9 +29,9 @@ import {
toPairs,
} from 'ramda'
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventDelegatorMetadataKey } from '../constants/base'
import { DatabaseClient, EventId } from '../@types/base'
import { DBEvent, Event } from '../@types/event'
import { EventDeduplicationMetadataKey, EventDelegatorMetadataKey } from '../constants/base'
import { IEventRepository, IQueryResult } from '../@types/repositories'
import { toBuffer, toJSON } from '../utils/transform'
import { createLogger } from '../factories/logger-factory'
@ -39,7 +40,7 @@ import { SubscriptionFilter } from '../@types/subscription'
const even = pipe(modulo(__, 2), equals(0))
const groupByLengthSpec = groupBy(
const groupByLengthSpec = groupBy<string, 'exact' | 'even' | 'odd'>(
pipe(
prop('length'),
cond([
@ -124,7 +125,7 @@ export class EventRepository implements IEventRepository {
if (typeof currentFilter.limit === 'number') {
builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC')
} else {
builder.orderBy('event_created_at', 'asc')
builder.limit(500).orderBy('event_created_at', 'asc')
}
const andWhereRaw = invoker(1, 'andWhereRaw')
@ -132,7 +133,7 @@ export class EventRepository implements IEventRepository {
pipe(
toPairs,
filter(pipe(nth(0), isGenericTagQuery)) as any,
filter(pipe(nth(0) as () => string, isGenericTagQuery)) as any,
forEach(([filterName, criteria]: [string, string[]]) => {
builder.andWhere((bd) => {
ifElse(
@ -180,6 +181,7 @@ export class EventRepository implements IEventRepository {
pipe(prop(EventDelegatorMetadataKey as any), toBuffer),
always(null),
),
remote_address: path([ContextMetadataKey as any, 'remoteAddress', 'address']),
})(event)
return this.masterDbClient('events')
@ -188,7 +190,6 @@ export class EventRepository implements IEventRepository {
.ignore()
}
public upsert(event: Event): Promise<number> {
debug('upserting event: %o', event)
@ -212,6 +213,7 @@ export class EventRepository implements IEventRepository {
pipe(paths([['pubkey'], ['kind']]), toJSON),
pipe(prop(EventDeduplicationMetadataKey as any), toJSON),
),
remote_address: path([ContextMetadataKey as any, 'remoteAddress', 'address']),
})(event)
const query = this.masterDbClient('events')

View File

@ -1,27 +1,59 @@
import {
always,
applySpec,
ifElse,
is,
isNil,
omit,
pipe,
prop,
propSatisfies,
toString,
when,
} from 'ramda'
import { DBInvoice, Invoice } from '../@types/invoice'
import { DBInvoice, Invoice, InvoiceStatus } from '../@types/invoice'
import { fromDBInvoice, toBuffer } from '../utils/transform'
import { createLogger } from '../factories/logger-factory'
import { DatabaseClient } from '../@types/base'
import { IInvoiceRepository } from '../@types/repositories'
import { randomUUID } from 'crypto'
const debug = createLogger('invoice-repository')
export class InvoiceRepository implements IInvoiceRepository {
public constructor(private readonly dbClient: DatabaseClient) { }
public async findById(id: string): Promise<Invoice> {
const [dbInvoice] = await this.dbClient<DBInvoice>('invoices').where('id', id).select()
public async confirmInvoice(
invoiceId: string,
amountPaid: bigint,
confirmedAt: Date,
client: DatabaseClient = this.dbClient,
): Promise<void> {
debug('confirming invoice %s at %s: %s', invoiceId, confirmedAt, amountPaid)
try {
await client.raw(
'select confirm_invoice(?, ?, ?)',
[
invoiceId,
amountPaid.toString(),
confirmedAt.toISOString(),
]
)
} catch (error) {
console.error('Unable to confirm invoice. Reason:', error.message)
throw error
}
}
public async findById(
id: string,
client: DatabaseClient = this.dbClient,
): Promise<Invoice | undefined> {
const [dbInvoice] = await client<DBInvoice>('invoices')
.where('id', id)
.select()
if (!dbInvoice) {
return
@ -30,22 +62,52 @@ export class InvoiceRepository implements IInvoiceRepository {
return fromDBInvoice(dbInvoice)
}
public upsert(invoice: Invoice): Promise<number> {
public async findPendingInvoices(
offset = 0,
limit = 10,
client: DatabaseClient = this.dbClient,
): Promise<Invoice[]> {
const dbInvoices = await client<DBInvoice>('invoices')
.where('status', InvoiceStatus.PENDING)
.offset(offset)
.limit(limit)
.select()
return dbInvoices.map(fromDBInvoice)
}
public upsert(
invoice: Invoice,
client: DatabaseClient = this.dbClient
): Promise<number> {
debug('upserting invoice: %o', invoice)
const row = applySpec({
id: when(propSatisfies(is(String), 'id'), prop('id')),
const row = applySpec<DBInvoice>({
id: ifElse(propSatisfies(is(String), 'id'), prop('id'), always(randomUUID())),
pubkey: pipe(prop('pubkey'), toBuffer),
bolt11: prop('bolt11'),
amount_requested: pipe(prop('amountRequested'), toString),
amount_paid: when(propSatisfies(is(BigInt), 'amountPaid'), pipe(prop('amountPaid'), toString)),
// amount_paid: ifElse(propSatisfies(is(BigInt), 'amountPaid'), pipe(prop('amountPaid'), toString), always(null)),
unit: prop('unit'),
status: prop('status'),
description: prop('description'),
confirmed_at: prop('confirmedAt'),
expires_at: prop('expiresAt'),
// confirmed_at: prop('confirmedAt'),
expires_at: ifElse(
propSatisfies(isNil, 'expiresAt'),
always(undefined),
prop('expiresAt'),
),
updated_at: always(new Date()),
created_at: ifElse(
propSatisfies(isNil, 'createdAt'),
always(undefined),
prop('createdAt'),
),
})(invoice)
const query = this.dbClient('invoices')
debug('row: %o', row)
const query = client<DBInvoice>('invoices')
.insert(row)
.onConflict('id')
.merge(

View File

@ -0,0 +1,81 @@
import { always, applySpec, omit, pipe, prop } from 'ramda'
import { DatabaseClient, Pubkey } from '../@types/base'
import { DBUser, User } from '../@types/user'
import { fromDBUser, toBuffer } from '../utils/transform'
import { createLogger } from '../factories/logger-factory'
import { IUserRepository } from '../@types/repositories'
const debug = createLogger('user-repository')
export class UserRepository implements IUserRepository {
public constructor(private readonly dbClient: DatabaseClient) { }
public async findByPubkey(
pubkey: Pubkey,
client: DatabaseClient = this.dbClient
): Promise<User | undefined> {
debug('find by pubkey: %s', pubkey)
const [dbuser] = await client<DBUser>('users')
.where('pubkey', toBuffer(pubkey))
.select()
if (!dbuser) {
return
}
return fromDBUser(dbuser)
}
public async upsert(
user: User,
client: DatabaseClient = this.dbClient,
): Promise<number> {
debug('upsert: %o', user)
const date = new Date()
const row = applySpec<DBUser>({
pubkey: pipe(prop('pubkey'), toBuffer),
is_admitted: prop('isAdmitted'),
tos_accepted_at: prop('tosAcceptedAt'),
updated_at: always(date),
created_at: always(date),
})(user)
const query = client<DBUser>('users')
.insert(row)
.onConflict('pubkey')
.merge(
omit([
'pubkey',
'balance',
'created_at',
])(row)
)
return {
then: <T1, T2>(onfulfilled: (value: number) => T1 | PromiseLike<T1>, onrejected: (reason: any) => T2 | PromiseLike<T2>) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected),
catch: <T>(onrejected: (reason: any) => T | PromiseLike<T>) => query.catch(onrejected),
toString: (): string => query.toString(),
} as Promise<number>
}
public async getBalanceByPubkey(
pubkey: Pubkey,
client: DatabaseClient = this.dbClient
): Promise<bigint> {
debug('get balance for pubkey: %s', pubkey)
const [user] = await client<DBUser>('users')
.select('balance')
.where('pubkey', toBuffer(pubkey))
.limit(1)
if (!user) {
return 0n
}
return BigInt(user.balance)
}
}

View File

@ -1,10 +1,30 @@
import { json, Router } from 'express'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { getRemoteAddress } from '../../utils/http'
import { postZebedeeCallbackRequestHandler } from '../../handlers/request-handlers/post-zebedee-callback-request-handler'
const router = Router()
const debug = createLogger('routes-callbacks')
router.post('/zebedee', json(), postZebedeeCallbackRequestHandler)
const router = Router()
router
.use((req, res, next) => {
const settings = createSettings()
const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
const remoteAddress = getRemoteAddress(req, settings)
if (ipWhitelist.length && !ipWhitelist.includes(remoteAddress)) {
debug('unauthorized request from %s to /callbacks/zebedee', remoteAddress)
res
.status(403)
.send('Forbidden')
return
}
next()
})
.post('/zebedee', json(), postZebedeeCallbackRequestHandler)
export default router

View File

@ -1,11 +1,17 @@
import { Router, urlencoded } from 'express'
import { createPaymentsProcessor } from '../../factories/payments-processor-factory'
import { getInvoiceRequestHandler } from '../../handlers/request-handlers/get-invoice-request-handler'
import { postInvoiceRequestHandler } from '../../handlers/request-handlers/post-invoice-request-handler'
const invoiceRouter = Router()
invoiceRouter.get('/', getInvoiceRequestHandler)
invoiceRouter.post('/', urlencoded({ extended: true }), postInvoiceRequestHandler)
invoiceRouter
.use((req, _res, next) => {
req['paymentsProcessor'] = createPaymentsProcessor()
next()
})
.get('/', getInvoiceRequestHandler)
.post('/', urlencoded({ extended: true }), postInvoiceRequestHandler)
export default invoiceRouter

View File

@ -0,0 +1,312 @@
import { andThen, pipe } from 'ramda'
import { broadcastEvent, encryptKind4Event, getPrivateKeyFromSecret, getPublicKey, identifyEvent, signEvent } from '../utils/event'
import { DatabaseClient, Pubkey } from '../@types/base'
import { FeeSchedule, Settings } from '../@types/settings'
import { IEventRepository, IInvoiceRepository, IUserRepository } from '../@types/repositories'
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
import { createLogger } from '../factories/logger-factory'
import { EventKinds } from '../constants/base'
import { IPaymentsProcessor } from '../@types/clients'
import { IPaymentsService } from '../@types/services'
import { toBech32 } from '../utils/transform'
import { Transaction } from '../database/transaction'
import { UnidentifiedEvent } from '../@types/event'
const debug = createLogger('payments-service')
export class PaymentsService implements IPaymentsService {
public constructor(
private readonly dbClient: DatabaseClient,
private readonly paymentsProcessor: IPaymentsProcessor,
private readonly userRepository: IUserRepository,
private readonly invoiceRepository: IInvoiceRepository,
private readonly eventRepository: IEventRepository,
private readonly settings: () => Settings
) {}
public async getPendingInvoices(): Promise<Invoice[]> {
debug('get pending invoices')
try {
return await this.invoiceRepository.findPendingInvoices(0, 10)
} catch (error) {
console.log('Unable to get pending invoices.', error)
throw error
}
}
public async getInvoiceFromPaymentsProcessor(invoiceId: string): Promise<Invoice> {
debug('get invoice %s from payment processor', invoiceId)
try {
return await this.paymentsProcessor.getInvoice(invoiceId)
} catch (error) {
console.log('Unable to get invoice from payments processor. Reason:', error)
throw error
}
}
public async createInvoice(
pubkey: Pubkey,
amount: bigint,
description: string,
): Promise<Invoice> {
debug('create invoice for %s for %s: %d', pubkey, amount.toString(), description)
const transaction = new Transaction(this.dbClient)
try {
await transaction.begin()
await this.userRepository.upsert({ pubkey }, transaction.transaction)
const invoiceResponse = await this.paymentsProcessor.createInvoice(
{
amount,
description,
requestId: pubkey,
},
)
const date = new Date()
await this.invoiceRepository.upsert(
{
id: invoiceResponse.id,
pubkey,
bolt11: invoiceResponse.bolt11,
amountRequested: invoiceResponse.amountRequested,
description: invoiceResponse.description,
unit: invoiceResponse.unit,
status: invoiceResponse.status,
expiresAt: invoiceResponse.expiresAt,
updatedAt: date,
createdAt: date,
},
transaction.transaction,
)
await transaction.commit()
return {
id: invoiceResponse.id,
pubkey,
bolt11: invoiceResponse.bolt11,
amountRequested: invoiceResponse.amountRequested,
unit: invoiceResponse.unit,
status: invoiceResponse.status,
description,
expiresAt: invoiceResponse.expiresAt,
updatedAt: date,
createdAt: invoiceResponse.createdAt,
}
} catch (error) {
await transaction.rollback()
console.error('Unable to create invoice:', error)
throw error
}
}
public async updateInvoice(invoice: Invoice): Promise<void> {
debug('update invoice %s: %o', invoice.id, invoice)
try {
await this.invoiceRepository.upsert({
id: invoice.id,
pubkey: invoice.pubkey,
bolt11: invoice.bolt11,
amountRequested: invoice.amountRequested,
description: invoice.description,
unit: invoice.unit,
status: invoice.status,
expiresAt: invoice.expiresAt,
updatedAt: new Date(),
createdAt: invoice.createdAt,
})
} catch (error) {
console.error('Unable to update invoice. Reason:', error)
throw error
}
}
public async confirmInvoice(
invoice: Invoice,
): Promise<void> {
debug('confirm invoice %s: %o', invoice.id, invoice)
const transaction = new Transaction(this.dbClient)
try {
if (!invoice.confirmedAt) {
throw new Error('Invoince confirmation date is not set')
}
if (invoice.status !== InvoiceStatus.COMPLETED) {
throw new Error(`Invoice is not complete: ${invoice.status}`)
}
if (typeof invoice.amountPaid !== 'bigint') {
throw new Error(`Invoice paid amount is not set: ${invoice.amountPaid}`)
}
await transaction.begin()
await this.invoiceRepository.confirmInvoice(
invoice.id,
invoice.amountPaid,
invoice.confirmedAt,
transaction.transaction
)
const currentSettings = this.settings()
const isApplicableFee = (feeSchedule: FeeSchedule) => feeSchedule.enabled
&& !feeSchedule.whitelists?.pubkeys?.some((prefix) => invoice.pubkey.startsWith(prefix))
const admissionFeeSchedules = currentSettings.payments?.feeSchedules?.admission ?? []
const admissionFeeAmount = admissionFeeSchedules
.reduce((sum, feeSchedule) => {
return sum + (isApplicableFee(feeSchedule) ? BigInt(feeSchedule.amount) : 0n)
}, 0n)
if (
admissionFeeAmount > 0n
&& invoice.amountPaid >= admissionFeeAmount
) {
const date = new Date()
// TODO: Convert to stored func
await this.userRepository.upsert(
{
pubkey: invoice.pubkey,
isAdmitted: true,
tosAcceptedAt: date,
updatedAt: date,
},
transaction.transaction,
)
}
await transaction.commit()
} catch (error) {
console.error('Unable to confirm invoice. Reason:', error)
await transaction.rollback()
throw error
}
}
public async sendNewInvoiceNotification(invoice: Invoice): Promise<void> {
debug('invoice created notification %s: %o', invoice.id, invoice)
const currentSettings = this.settings()
const {
info: {
relay_url: relayUrl,
name: relayName,
},
} = currentSettings
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
const relayPubkey = getPublicKey(relayPrivkey)
let unit: string = invoice.unit
let amount: bigint = invoice.amountRequested
if (invoice.unit === InvoiceUnit.MSATS) {
amount /= 1000n
unit = 'sats'
}
const url = new URL(relayUrl)
const terms = new URL(relayUrl)
terms.protocol = ['https', 'wss'].includes(url.protocol)
? 'https'
: 'http'
terms.pathname += 'terms'
const unsignedInvoiceEvent: UnidentifiedEvent = {
pubkey: relayPubkey,
kind: EventKinds.ENCRYPTED_DIRECT_MESSAGE,
created_at: Math.floor(invoice.createdAt.getTime() / 1000),
content: `From: ${relayPubkey}@${url.hostname} (${relayName})
To: ${toBech32('npub')(invoice.pubkey)}@${url.hostname}
🧾 Admission Fee Invoice
Amount: ${amount.toString()} ${unit}
By paying this invoice, you confirm that you have read and agree to the Terms of Service:
${terms.toString()}
Expires at ${invoice.expiresAt.toISOString()}
${invoice.bolt11}`,
tags: [
['p', invoice.pubkey],
['bolt11', invoice.bolt11],
],
}
const persistEvent = this.eventRepository.create.bind(this.eventRepository)
await pipe(
identifyEvent,
andThen(encryptKind4Event(relayPrivkey, invoice.pubkey)),
andThen(signEvent(relayPrivkey)),
andThen(broadcastEvent),
andThen(persistEvent),
)(unsignedInvoiceEvent)
}
public async sendInvoiceUpdateNotification(invoice: Invoice): Promise<void> {
debug('invoice updated notification %s: %o', invoice.id, invoice)
const currentSettings = this.settings()
const {
info: {
relay_url: relayUrl,
name: relayName,
},
} = currentSettings
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
const relayPubkey = getPublicKey(relayPrivkey)
let unit: string = invoice.unit
let amount: bigint | undefined = invoice.amountPaid
if (typeof amount === 'undefined') {
const message = `Unable to notify user ${invoice.pubkey} for invoice ${invoice.id}`
throw new Error(message)
}
if (invoice.unit === InvoiceUnit.MSATS) {
amount /= 1000n
unit = InvoiceUnit.SATS
}
const url = new URL(relayUrl)
const unsignedInvoiceEvent: UnidentifiedEvent = {
pubkey: relayPubkey,
kind: EventKinds.ENCRYPTED_DIRECT_MESSAGE,
created_at: Math.floor(invoice.createdAt.getTime() / 1000),
content: `🧾 Admission Fee Invoice Paid for ${relayPubkey}@${url.hostname} (${relayName})
Amount received: ${amount.toString()} ${unit}
Thanks!`,
tags: [
['p', invoice.pubkey],
['c', invoice.id],
],
}
const persistEvent = this.eventRepository.create.bind(this.eventRepository)
await pipe(
identifyEvent,
andThen(encryptKind4Event(relayPrivkey, invoice.pubkey)),
andThen(signEvent(relayPrivkey)),
andThen(broadcastEvent),
andThen(persistEvent),
)(unsignedInvoiceEvent)
}
}

View File

@ -1,6 +1,7 @@
import * as secp256k1 from '@noble/secp256k1'
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
import { createHmac } from 'crypto'
import { createCipheriv, createHmac, getRandomValues } from 'crypto'
import cluster from 'cluster'
import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event'
import { EventId, Pubkey, Tag } from '../@types/base'
@ -11,6 +12,7 @@ import { getLeadingZeroBits } from './proof-of-work'
import { isGenericTagQuery } from './filter'
import { RuneLike } from './runes/rune-like'
import { SubscriptionFilter } from '../@types/subscription'
import { WebSocketServerAdapterEvent } from '../constants/adapter'
export const serializeEvent = (event: UnidentifiedEvent): CanonicalEvent => [
0,
@ -186,13 +188,63 @@ export const getPrivateKeyFromSecret =
return hmac.digest().toString('hex')
}
export const getPublicKey = (privkey: string | Buffer) => Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2)
export const getPublicKey = (privkey: string | Buffer) => Buffer.from(secp256k1.getPublicKey(privkey, true)).subarray(1).toString('hex')
export const signEvent = (privkey: string | Buffer | undefined) => async (event: UnsignedEvent): Promise<Event> => {
const sig = await secp256k1.schnorr.sign(event.id, privkey)
const sig = await secp256k1.schnorr.sign(event.id, privkey as any)
return { ...event, sig: Buffer.from(sig).toString('hex') }
}
export const encryptKind4Event = (
senderPrivkey: string | Buffer,
receiverPubkey: Pubkey,
) => (event: UnsignedEvent): UnsignedEvent => {
const key = secp256k1
.getSharedSecret(senderPrivkey,`02${receiverPubkey}`, true)
.subarray(1)
const iv = getRandomValues(new Uint8Array(16))
// deepcode ignore InsecureCipherNoIntegrity: NIP-04 Encrypted Direct Message uses aes-256-cbc
const cipher = createCipheriv(
'aes-256-cbc',
Buffer.from(key),
iv,
)
let content = cipher.update(event.content, 'utf8', 'base64')
content += cipher.final('base64')
content += '?iv=' + Buffer.from(iv.buffer).toString('base64')
return {
...event,
content,
}
}
export const broadcastEvent = async (event: Event): Promise<Event> => {
return new Promise((resolve, reject) => {
if (!cluster.isWorker || typeof process.send === 'undefined') {
return Promise.resolve(event)
}
process.send(
{
eventName: WebSocketServerAdapterEvent.Broadcast,
event,
},
undefined,
undefined,
(error: Error | null) => {
if (error) {
return reject(error)
}
resolve(event)
},
)
})
}
export const isReplaceableEvent = (event: Event): boolean => {
return event.kind === EventKinds.SET_METADATA
|| event.kind === EventKinds.CONTACT_LIST

View File

@ -1,8 +1,8 @@
import { IncomingMessage } from 'http'
import { ISettings } from '../@types/settings'
import { Settings } from '../@types/settings'
export const getRemoteAddress = (request: IncomingMessage, settings: ISettings): string => {
export const getRemoteAddress = (request: IncomingMessage, settings: Settings): string => {
let header: string | undefined
// TODO: Remove deprecation warning
if ('network' in settings && 'remote_ip_header' in settings.network) {
@ -13,5 +13,7 @@ export const getRemoteAddress = (request: IncomingMessage, settings: ISettings):
header = settings.network.remoteIpHeader as string
}
return (request.headers[header] ?? request.socket.remoteAddress) as string
const result = (request.headers[header] ?? request.socket.remoteAddress) as string
return result.split(',')[0]
}

5
src/utils/misc.ts Normal file
View File

@ -0,0 +1,5 @@
export const delayMs = (ms: number): Promise<void> => new Promise<void>(
(resolve) => {
setTimeout(resolve, ms)
}
)

View File

@ -5,7 +5,7 @@ import { extname, join } from 'path'
import { mergeDeepRight } from 'ramda'
import { createLogger } from '../factories/logger-factory'
import { ISettings } from '../@types/settings'
import { Settings } from '../@types/settings'
const debug = createLogger('settings')
@ -15,7 +15,7 @@ export enum SettingsFileTypes {
}
export class SettingsStatic {
static _settings: ISettings
static _settings: Settings
public static getSettingsFileBasePath(): string {
return process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr')
@ -25,9 +25,9 @@ export class SettingsStatic {
return join(process.cwd(), 'resources', 'default-settings.yaml')
}
public static loadAndParseYamlFile(path: string): ISettings {
public static loadAndParseYamlFile(path: string): Settings {
const defaultSettingsFileContent = fs.readFileSync(path, { encoding: 'utf-8' })
const defaults = yaml.load(defaultSettingsFileContent) as ISettings
const defaults = yaml.load(defaultSettingsFileContent) as Settings
return defaults
}
@ -44,7 +44,7 @@ export class SettingsStatic {
const files: string[] = fs.readdirSync(path)
const filteredFile = files.find(fn => fn.startsWith('settings'))
if (filteredFile) {
const extension = extname(filteredFile)
const extension = extname(filteredFile).substring(1)
return SettingsFileTypes[extension]
}
}
@ -66,7 +66,7 @@ export class SettingsStatic {
}
}
public static createSettings(): ISettings {
public static createSettings(): Settings {
if (SettingsStatic._settings) {
return SettingsStatic._settings
}
@ -78,12 +78,11 @@ export class SettingsStatic {
}
const defaultsFilePath = SettingsStatic.getDefaultSettingsFilePath()
const fileType = SettingsStatic.settingsFileType(basePath)
const settingsFilePath = `${basePath}/settings.${fileType}`
const settingsFilePath = join(basePath, `settings.${fileType}`)
const defaults = SettingsStatic.loadSettings(defaultsFilePath, SettingsFileTypes.yaml)
try {
if (fileType) {
SettingsStatic._settings = mergeDeepRight(
defaults,
@ -102,7 +101,7 @@ export class SettingsStatic {
}
}
public static saveSettings(path: string, settings: ISettings) {
public static saveSettings(path: string, settings: Settings) {
debug('saving settings to %s: %o', path, settings)
return fs.writeFileSync(
join(path, 'settings.yaml'),
@ -110,4 +109,22 @@ export class SettingsStatic {
{ encoding: 'utf-8' },
)
}
public static watchSettings() {
const basePath = SettingsStatic.getSettingsFileBasePath()
const defaultsFilePath = SettingsStatic.getDefaultSettingsFilePath()
const fileType = SettingsStatic.settingsFileType(basePath)
const settingsFilePath = join(basePath, `settings.${fileType}`)
const reload = () => {
console.log('reloading settings')
SettingsStatic._settings = undefined
SettingsStatic.createSettings()
}
return [
fs.watch(defaultsFilePath, 'utf8', reload),
fs.watch(settingsFilePath, 'utf8', reload),
]
}
}

View File

@ -1,8 +1,8 @@
import { applySpec, is, path, pathEq, pipe, prop, propSatisfies, when } from 'ramda'
import { always, applySpec, ifElse, is, isNil, path, pipe, prop, propSatisfies } from 'ramda'
import { bech32 } from 'bech32'
import { DBInvoice, Invoice } from '../@types/invoice'
import { Pubkey } from '../@types/base'
import { Invoice } from '../@types/invoice'
import { User } from '../@types/user'
export const toJSON = (input: any) => JSON.stringify(input)
@ -14,12 +14,16 @@ export const toBigInt = (input: string): bigint => BigInt(input)
export const fromBigInt = (input: bigint) => input.toString()
export const fromDBInvoice = (input: DBInvoice): Invoice => applySpec<Invoice>({
id: prop('id') as () => Pubkey,
export const fromDBInvoice = applySpec<Invoice>({
id: prop('id') as () => string,
pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer),
bolt11: prop('bolt11'),
amountRequested: pipe(prop('amount_requested'), toBigInt),
amountPaid: pipe(prop('amount_paid'), toBigInt),
amountRequested: pipe(prop('amount_requested') as () => string, toBigInt),
amountPaid: ifElse(
propSatisfies(isNil, 'amount_paid'),
always(undefined),
pipe(prop('amount_paid') as () => string, toBigInt),
),
unit: prop('unit'),
status: prop('status'),
description: prop('description'),
@ -27,12 +31,20 @@ export const fromDBInvoice = (input: DBInvoice): Invoice => applySpec<Invoice>({
expiresAt: prop('expires_at'),
updatedAt: prop('updated_at'),
createdAt: prop('created_at'),
})(input)
})
export const fromNpub = (npub: string) => {
const { prefix, words } = bech32.decode(npub)
if (prefix !== 'npub') {
throw new Error('not an npub key')
export const fromDBUser = applySpec<User>({
pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer),
isAdmitted: prop('is_admitted'),
balance: prop('balance'),
createdAt: prop('created_at'),
updatedAt: prop('updated_at'),
})
export const fromBech32 = (input: string) => {
const { prefix, words } = bech32.decode(input)
if (!input.startsWith(prefix)) {
throw new Error(`Bech32 invalid prefix: ${prefix}`)
}
return Buffer.from(
@ -40,28 +52,34 @@ export const fromNpub = (npub: string) => {
).toString('hex')
}
export const toDate = (input: string) => new Date(input)
export const toBech32 = (prefix: string) => (input: string): string => {
return bech32.encode(prefix, bech32.toWords(Buffer.from(input, 'hex')))
}
export const toDate = (input: string) => new Date(input)
export const fromZebedeeInvoice = applySpec<Invoice>({
id: prop('id'),
pubkey: prop('internalId'),
bolt11: path(['invoice', 'request']),
amountRequested: pipe(prop('amount'), toBigInt),
amountPaid: when(
pathEq(['status'], 'completed'),
pipe(prop('amount'), toBigInt),
),
amountRequested: pipe(prop('amount') as () => string, toBigInt),
description: prop('description'),
unit: prop('unit'),
status: prop('status'),
description: prop('description'),
confirmedAt: when(
propSatisfies(is(String), 'confirmed_at'),
pipe(prop('confirmed_at'), toDate),
expiresAt: ifElse(
propSatisfies(is(String), 'expiresAt'),
pipe(prop('expiresAt'), toDate),
always(null),
),
expiresAt: when(
propSatisfies(is(String), 'confirmed_at'),
pipe(prop('expires_at'), toDate),
confirmedAt: ifElse(
propSatisfies(is(String), 'confirmedAt'),
pipe(prop('confirmedAt'), toDate),
always(null),
),
createdAt: pipe(prop('created_at'), toDate),
createdAt: ifElse(
propSatisfies(is(String), 'createdAt'),
pipe(prop('createdAt'), toDate),
always(null),
),
rawRespose: toJSON,
})

View File

@ -1,11 +1,11 @@
import { expect } from 'chai'
import { IEventRepository, IUserRepository } from '../../../src/@types/repositories'
import { IncomingMessage, MessageType } from '../../../src/@types/messages'
import { DelegatedEventMessageHandler } from '../../../src/handlers/delegated-event-message-handler'
import { Event } from '../../../src/@types/event'
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
import { EventTags } from '../../../src/constants/base'
import { IEventRepository } from '../../../src/@types/repositories'
import { IWebSocketAdapter } from '../../../src/@types/adapters'
import { messageHandlerFactory } from '../../../src/factories/message-handler-factory'
import { SubscribeMessageHandler } from '../../../src/handlers/subscribe-message-handler'
@ -14,17 +14,19 @@ import { UnsubscribeMessageHandler } from '../../../src/handlers/unsubscribe-mes
describe('messageHandlerFactory', () => {
let event: Event
let eventRepository: IEventRepository
let userRepository: IUserRepository
let message: IncomingMessage
let adapter: IWebSocketAdapter
let factory
beforeEach(() => {
eventRepository = {} as any
userRepository = {} as any
adapter = {} as any
event = {
tags: [],
} as any
factory = messageHandlerFactory(eventRepository)
factory = messageHandlerFactory(eventRepository, userRepository)
})
it('returns EventMessageHandler when given an EVENT message', () => {

View File

@ -3,7 +3,7 @@ import { IncomingMessage } from 'http'
import Sinon from 'sinon'
import WebSocket from 'ws'
import { IEventRepository } from '../../../src/@types/repositories'
import { IEventRepository, IUserRepository } from '../../../src/@types/repositories'
import { IWebSocketServerAdapter } from '../../../src/@types/adapters'
import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter'
import { webSocketAdapterFactory } from '../../../src/factories/websocket-adapter-factory'
@ -21,6 +21,7 @@ describe('webSocketAdapterFactory', () => {
it('returns a WebSocketAdapter', () => {
const eventRepository: IEventRepository = {} as any
const userRepository: IUserRepository = {} as any
const client: WebSocket = {
on: onStub,
@ -37,7 +38,7 @@ describe('webSocketAdapterFactory', () => {
const webSocketServerAdapter: IWebSocketServerAdapter = {} as any
expect(
webSocketAdapterFactory(eventRepository)([client, request, webSocketServerAdapter])
webSocketAdapterFactory(eventRepository, userRepository)([client, request, webSocketServerAdapter])
).to.be.an.instanceOf(WebSocketAdapter)
})
})

View File

@ -6,6 +6,7 @@ import * as databaseClientModule from '../../../src/database/client'
import { AppWorker } from '../../../src/app/worker'
import { workerFactory } from '../../../src/factories/worker-factory'
describe('workerFactory', () => {
let getMasterDbClientStub: Sinon.SinonStub
let getReadReplicaDbClientStub: Sinon.SinonStub

View File

@ -11,6 +11,7 @@ import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
import { DelegatedEventMessageHandler } from '../../../src/handlers/delegated-event-message-handler'
import { Event } from '../../../src/@types/event'
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
import { IUserRepository } from '../../../src/@types/repositories'
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
const { expect } = chai
@ -18,11 +19,12 @@ const { expect } = chai
describe('DelegatedEventMessageHandler', () => {
let webSocket: EventEmitter
let handler: DelegatedEventMessageHandler
let userRepository: IUserRepository
let event: Event
let message: IncomingEventMessage
let sandbox: Sinon.SinonSandbox
let originalConsoleWarn: (message?: any, ...optionalParams: any[]) => void | undefined = undefined
let originalConsoleWarn: any = undefined
beforeEach(() => {
sandbox = Sinon.createSandbox()
@ -53,10 +55,12 @@ describe('DelegatedEventMessageHandler', () => {
let onMessageSpy: Sinon.SinonSpy
let strategyExecuteStub: Sinon.SinonStub
let isRateLimitedStub: Sinon.SinonStub
let isUserAdmitted: Sinon.SinonStub
beforeEach(() => {
canAcceptEventStub = sandbox.stub(DelegatedEventMessageHandler.prototype, 'canAcceptEvent' as any)
isEventValidStub = sandbox.stub(DelegatedEventMessageHandler.prototype, 'isEventValid' as any)
isUserAdmitted = sandbox.stub(EventMessageHandler.prototype, 'isUserAdmitted' as any)
strategyExecuteStub = sandbox.stub()
strategyFactoryStub = sandbox.stub().returns({
execute: strategyExecuteStub,
@ -69,6 +73,7 @@ describe('DelegatedEventMessageHandler', () => {
handler = new DelegatedEventMessageHandler(
webSocket as any,
strategyFactoryStub,
userRepository,
() => ({}) as any,
() => ({ hit: async () => false }),
)
@ -119,6 +124,21 @@ describe('DelegatedEventMessageHandler', () => {
expect(strategyFactoryStub).not.to.have.been.called
})
it('rejects event is user is not admitted', async () => {
isUserAdmitted.resolves('not admitted')
await handler.handleMessage(message)
expect(isRateLimitedStub).to.have.been.calledOnceWithExactly(event)
expect(isUserAdmitted).to.have.been.calledOnceWithExactly(event)
expect(onMessageSpy).to.have.been.calledOnceWithExactly([
MessageType.OK,
event.id,
false,
'not admitted',
])
})
it('does not call strategy if none given', async () => {
isEventValidStub.returns(undefined)
canAcceptEventStub.returns(undefined)

View File

@ -3,14 +3,17 @@ import EventEmitter from 'events'
import Sinon, { SinonFakeTimers, SinonStub } from 'sinon'
import chai from 'chai'
import chaiAsPromised from 'chai-as-promised'
import sinonChai from 'sinon-chai'
chai.use(sinonChai)
chai.use(chaiAsPromised)
import { EventLimits, ISettings } from '../../../src/@types/settings'
import { EventLimits, Settings } from '../../../src/@types/settings'
import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
import { Event } from '../../../src/@types/event'
import { EventKinds } from '../../../src/constants/base'
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
import { IUserRepository } from '../../../src/@types/repositories'
import { IWebSocketAdapter } from '../../../src/@types/adapters'
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
@ -19,6 +22,7 @@ const { expect } = chai
describe('EventMessageHandler', () => {
let webSocket: IWebSocketAdapter
let handler: EventMessageHandler
let userRepository: IUserRepository
let event: Event
let message: IncomingEventMessage
let sandbox: Sinon.SinonSandbox
@ -52,10 +56,12 @@ describe('EventMessageHandler', () => {
let onMessageSpy: Sinon.SinonSpy
let strategyExecuteStub: Sinon.SinonStub
let isRateLimitedStub: Sinon.SinonStub
let isUserAdmitted: Sinon.SinonStub
beforeEach(() => {
canAcceptEventStub = sandbox.stub(EventMessageHandler.prototype, 'canAcceptEvent' as any)
isEventValidStub = sandbox.stub(EventMessageHandler.prototype, 'isEventValid' as any)
isUserAdmitted = sandbox.stub(EventMessageHandler.prototype, 'isUserAdmitted' as any)
strategyExecuteStub = sandbox.stub()
strategyFactoryStub = sandbox.stub().returns({
execute: strategyExecuteStub,
@ -68,6 +74,7 @@ describe('EventMessageHandler', () => {
handler = new EventMessageHandler(
webSocket as any,
strategyFactoryStub,
userRepository,
() => ({}) as any,
() => ({ hit: async () => false })
)
@ -113,6 +120,15 @@ describe('EventMessageHandler', () => {
expect(strategyFactoryStub).not.to.have.been.called
})
it('rejects event if user is not admitted', async () => {
isUserAdmitted.resolves('reason')
await handler.handleMessage(message)
expect(isUserAdmitted).to.have.been.calledWithExactly(event)
expect(strategyFactoryStub).not.to.have.been.called
})
it('does not call strategy if none given', async () => {
isEventValidStub.returns(undefined)
canAcceptEventStub.returns(undefined)
@ -156,7 +172,7 @@ describe('EventMessageHandler', () => {
describe('canAcceptEvent', () => {
let eventLimits: EventLimits
let settings: ISettings
let settings: Settings
let clock: SinonFakeTimers
beforeEach(() => {
@ -175,7 +191,7 @@ describe('EventMessageHandler', () => {
whitelist: [],
},
pubkey: {
minBalanceMsats: 0,
minBalance: 0n,
minLeadingZeroBits: 0,
blacklist: [],
whitelist: [],
@ -192,6 +208,7 @@ describe('EventMessageHandler', () => {
handler = new EventMessageHandler(
{} as any,
() => null,
userRepository,
() => settings,
() => ({ hit: async () => false })
)
@ -640,8 +657,9 @@ describe('EventMessageHandler', () => {
describe('isRateLimited', () => {
let eventLimits: EventLimits
let settings: ISettings
let settings: Settings
let rateLimiterHitStub: SinonStub
let userRepository: IUserRepository
let getClientAddressStub: Sinon.SinonStub
let webSocket: IWebSocketAdapter
@ -662,6 +680,7 @@ describe('EventMessageHandler', () => {
handler = new EventMessageHandler(
webSocket,
() => null,
userRepository,
() => settings,
() => ({ hit: rateLimiterHitStub })
)

View File

@ -11,8 +11,8 @@ chai.use(sinonChai)
const { expect } = chai
import { ContextMetadataKey, EventDeduplicationMetadataKey } from '../../../src/constants/base'
import { DatabaseClient } from '../../../src/@types/base'
import { EventDeduplicationMetadataKey } from '../../../src/constants/base'
import { EventRepository } from '../../../src/repositories/event-repository'
describe('EventRepository', () => {
@ -35,6 +35,7 @@ describe('EventRepository', () => {
})
afterEach(() => {
dbClient.destroy()
sandbox.restore()
})
@ -46,10 +47,6 @@ describe('EventRepository', () => {
expect(repository.findByFilters([{}])).to.have.property('finally')
})
it('throws error if filters is not an array', () => {
expect(() => repository.findByFilters('' as any)).to.throw(Error, 'Filters cannot be empty')
})
it('throws error if filters is empty', () => {
expect(() => repository.findByFilters([])).to.throw(Error, 'Filters cannot be empty')
})
@ -60,7 +57,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" order by "event_created_at" asc')
expect(query).to.equal('select * from "events" order by "event_created_at" asc limit 500')
})
describe('authors', () => {
@ -69,7 +66,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc limit 500')
})
it('selects events by one author', () => {
@ -77,7 +74,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\') or "event_delegator" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\')) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\') or "event_delegator" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\')) order by "event_created_at" asc limit 500')
})
it('selects events by two authors', () => {
@ -92,7 +89,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\') or "event_delegator" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\')) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\') or "event_delegator" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\')) order by "event_created_at" asc limit 500')
})
it('selects events by one author prefix (even length)', () => {
@ -106,7 +103,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_delegator" from 1 for 3) = X\'22e804\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_delegator" from 1 for 3) = X\'22e804\') order by "event_created_at" asc limit 500')
})
it('selects events by one author prefix (odd length)', () => {
@ -120,7 +117,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\' or substring("event_delegator" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\' or substring("event_delegator" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\') order by "event_created_at" asc limit 500')
})
it('selects events by two author prefix (first even, second odd)', () => {
@ -135,7 +132,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_delegator" from 1 for 3) = X\'22e804\' or substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\' or substring("event_delegator" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_delegator" from 1 for 3) = X\'22e804\' or substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\' or substring("event_delegator" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\') order by "event_created_at" asc limit 500')
})
})
@ -145,7 +142,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc limit 500')
})
it('selects events by one id', () => {
@ -153,7 +150,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_id" in (X\'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\')) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_id" in (X\'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\')) order by "event_created_at" asc limit 500')
})
it('selects events by two ids', () => {
@ -168,7 +165,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_id" in (X\'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\', X\'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\')) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_id" in (X\'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\', X\'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\')) order by "event_created_at" asc limit 500')
})
it('selects events by one id prefix (even length)', () => {
@ -182,7 +179,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_id" from 1 for 2) = X\'abcd\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_id" from 1 for 2) = X\'abcd\') order by "event_created_at" asc limit 500')
})
it('selects events by one id prefix (odd length)', () => {
@ -196,7 +193,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_id" from 1 for 2) BETWEEN E\'\\\\xabc0\' AND E\'\\\\xabcf\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_id" from 1 for 2) BETWEEN E\'\\\\xabc0\' AND E\'\\\\xabcf\') order by "event_created_at" asc limit 500')
})
it('selects events by two id prefix (first even, second odd)', () => {
@ -211,7 +208,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_id" from 1 for 3) = X\'abcdef\' or substring("event_id" from 1 for 2) BETWEEN E\'\\\\xabc0\' AND E\'\\\\xabcf\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_id" from 1 for 3) = X\'abcdef\' or substring("event_id" from 1 for 2) BETWEEN E\'\\\\xabc0\' AND E\'\\\\xabcf\') order by "event_created_at" asc limit 500')
})
})
@ -221,7 +218,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where 1 = 0 order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where 1 = 0 order by "event_created_at" asc limit 500')
})
it('selects events by one kind', () => {
@ -229,7 +226,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where "event_kind" in (1) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where "event_kind" in (1) order by "event_created_at" asc limit 500')
})
it('selects events by two kinds', () => {
@ -237,7 +234,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where "event_kind" in (1, 2) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where "event_kind" in (1, 2) order by "event_created_at" asc limit 500')
})
})
@ -247,7 +244,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc limit 500')
})
})
@ -257,7 +254,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc limit 500')
})
})
@ -277,7 +274,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc limit 500')
})
it('selects events by one #e tag', () => {
@ -285,7 +282,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["e","aaaaaa"]]\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["e","aaaaaa"]]\') order by "event_created_at" asc limit 500')
})
it('selects events by two #e tag', () => {
@ -293,7 +290,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["e","aaaaaa"]]\' or "event_tags" @> \'[["e","bbbbbb"]]\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["e","aaaaaa"]]\' or "event_tags" @> \'[["e","bbbbbb"]]\') order by "event_created_at" asc limit 500')
})
})
@ -303,7 +300,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc limit 500')
})
it('selects events by one #p tag', () => {
@ -311,7 +308,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["p","aaaaaa"]]\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["p","aaaaaa"]]\') order by "event_created_at" asc limit 500')
})
it('selects events by two #p tag', () => {
@ -319,7 +316,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["p","aaaaaa"]]\' or "event_tags" @> \'[["p","bbbbbb"]]\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["p","aaaaaa"]]\' or "event_tags" @> \'[["p","bbbbbb"]]\') order by "event_created_at" asc limit 500')
})
})
@ -329,7 +326,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (1 = 0) order by "event_created_at" asc limit 500')
})
it('selects events by one #r tag', () => {
@ -337,7 +334,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["r","aaaaaa"]]\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["r","aaaaaa"]]\') order by "event_created_at" asc limit 500')
})
it('selects events by two #r tag', () => {
@ -345,7 +342,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["r","aaaaaa"]]\' or "event_tags" @> \'[["r","bbbbbb"]]\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_tags" @> \'[["r","aaaaaa"]]\' or "event_tags" @> \'[["r","bbbbbb"]]\') order by "event_created_at" asc limit 500')
})
})
})
@ -356,7 +353,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('(select * from "events") union (select * from "events" order by "event_created_at" asc) order by "event_created_at" asc')
expect(query).to.equal('(select * from "events") union (select * from "events" order by "event_created_at" asc limit 500) order by "event_created_at" asc limit 500')
})
})
@ -366,7 +363,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('(select * from "events" where "event_kind" in (1)) union (select * from "events" where (substring("event_id" from 1 for 3) BETWEEN E\'\\\\xaaaaa0\' AND E\'\\\\xaaaaaf\') order by "event_created_at" asc) union (select * from "events" where (substring("event_pubkey" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\' or substring("event_delegator" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\') order by "event_created_at" asc) union (select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc) union (select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc) union (select * from "events" order by "event_created_at" DESC limit 1000) order by "event_created_at" asc')
expect(query).to.equal('(select * from "events" where "event_kind" in (1)) union (select * from "events" where (substring("event_id" from 1 for 3) BETWEEN E\'\\\\xaaaaa0\' AND E\'\\\\xaaaaaf\') order by "event_created_at" asc limit 500) union (select * from "events" where (substring("event_pubkey" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\' or substring("event_delegator" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\') order by "event_created_at" asc limit 500) union (select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc limit 500) union (select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc limit 500) union (select * from "events" order by "event_created_at" DESC limit 1000) order by "event_created_at" asc limit 500')
})
})
})
@ -433,11 +430,12 @@ describe('EventRepository', () => {
content:
"I've set up mirroring between relays: https://i.imgur.com/HxCDipB.png",
sig: 'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768',
[ContextMetadataKey]: { remoteAddress: { address: '::1' } as any },
}
const query = (repository as any).insert(event).toString()
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'I\'\'ve set up mirroring between relays: https://i.imgur.com/HxCDipB.png\', 1648351380, NULL, X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\', 1, X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768\', \'[["p","8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9","wss://nostr-pub.wellorder.net"],["e","7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96","wss://nostr-relay.untethr.me"]]\') on conflict do nothing')
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "remote_address") values (\'I\'\'ve set up mirroring between relays: https://i.imgur.com/HxCDipB.png\', 1648351380, NULL, X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\', 1, X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768\', \'[["p","8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9","wss://nostr-pub.wellorder.net"],["e","7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96","wss://nostr-relay.untethr.me"]]\', \'::1\') on conflict do nothing')
})
})
@ -477,11 +475,12 @@ describe('EventRepository', () => {
'tags': [],
'content': '{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}',
'sig': 'd1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9',
[ContextMetadataKey]: { remoteAddress: { address: '::1' } as any },
}
const query = repository.upsert(event).toString()
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503",0]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL where "events"."event_created_at" < 1564498626')
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "remote_address") values (\'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503",0]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', \'::1\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL,"remote_address" = \'::1\' where "events"."event_created_at" < 1564498626')
})
it('replaces event based on event_pubkey, event_kind and event_deduplication', () => {
@ -492,13 +491,14 @@ describe('EventRepository', () => {
'kind': 0,
'tags': [],
[EventDeduplicationMetadataKey]: ['deduplication'],
[ContextMetadataKey]: { remoteAddress: { address: '::1' } as any },
'content': '{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}',
'sig': 'd1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9',
}
const query = repository.upsert(event).toString()
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["deduplication"]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL where "events"."event_created_at" < 1564498626')
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "remote_address") values (\'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["deduplication"]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', \'::1\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL,"remote_address" = \'::1\' where "events"."event_created_at" < 1564498626')
})
})
})

View File

@ -47,22 +47,4 @@ describe('getRemoteAddress', () => {
)
).to.equal(socketAddress)
})
it('returns undefined if unable to find header', () => {
expect(
getRemoteAddress(
{ ...request, socket: {} } as any,
{ network: {} } as any,
)
).to.be.undefined
})
it('returns undefined if header setting is unset', () => {
expect(
getRemoteAddress(
{ ...request, socket: {} } as any,
{} as any,
)
).to.be.undefined
})
})

View File

@ -14,6 +14,9 @@ describe('SlidingWindowRateLimiter', () => {
let addToSortedSetStub: Sinon.SinonStub
let getRangeFromSortedSetStub: Sinon.SinonStub
let setKeyExpiryStub: Sinon.SinonStub
let getKeyStub: Sinon.SinonStub
let hasKeyStub: Sinon.SinonStub
let setKeyStub: Sinon.SinonStub
let sandbox: Sinon.SinonSandbox
@ -24,11 +27,17 @@ describe('SlidingWindowRateLimiter', () => {
addToSortedSetStub = sandbox.stub()
getRangeFromSortedSetStub = sandbox.stub()
setKeyExpiryStub = sandbox.stub()
getKeyStub = sandbox.stub()
hasKeyStub = sandbox.stub()
setKeyStub = sandbox.stub()
cache = {
removeRangeByScoreFromSortedSet: removeRangeByScoreFromSortedSetStub,
addToSortedSet: addToSortedSetStub,
getRangeFromSortedSet: getRangeFromSortedSetStub,
setKeyExpiry: setKeyExpiryStub,
getKey: getKeyStub,
hasKey: hasKeyStub,
setKey: setKeyStub,
}
rateLimiter = new SlidingWindowRateLimiter(cache)
})

View File

@ -6,7 +6,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2020",
"target": "ESNext",
"outDir": "./dist",
"moduleResolution": "Node",
"types": ["node", "mocha", "@cucumber/cucumber"],