mirror of
https://github.com/Cameri/nostream.git
synced 2025-05-02 22:20:16 +02:00
feat: massive update
Signed-off-by: Ricardo Arturo Cabral Mejía <me@ricardocabral.io>
This commit is contained in:
parent
55561847e8
commit
f9c53eeeb8
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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 })
|
||||
|
13
migrations/20230107_230900_create_users_table.js
Normal file
13
migrations/20230107_230900_create_users_table.js
Normal 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')
|
||||
}
|
70
migrations/20230118_190000_confirm_invoice_func.js
Normal file
70
migrations/20230118_190000_confirm_invoice_func.js
Normal 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();')
|
||||
}
|
14
migrations/20230119_170900_add_kind_tags_created_at_index.js
Normal file
14
migrations/20230119_170900_add_kind_tags_created_at_index.js
Normal 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;')
|
||||
}
|
27
migrations/20230120_161800_charge_user_func.js
Normal file
27
migrations/20230120_161800_charge_user_func.js
Normal 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);')
|
||||
}
|
@ -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
77
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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: []
|
||||
|
@ -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>
|
||||
|
@ -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(() => {
|
||||
|
@ -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 \
|
||||
|
@ -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
|
||||
|
||||
|
@ -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[]>
|
||||
|
@ -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
|
||||
}
|
@ -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
8
src/@types/database.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { DatabaseTransaction } from './base'
|
||||
|
||||
export interface ITransaction {
|
||||
begin(): Promise<void>
|
||||
get transaction (): DatabaseTransaction
|
||||
commit(): Promise<any[]>
|
||||
rollback(): Promise<any[]>
|
||||
}
|
@ -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'>
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 =
|
||||
|
@ -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
18
src/@types/services.ts
Normal 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[]>
|
||||
}
|
@ -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
18
src/@types/user.ts
Normal 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
|
||||
}
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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]))
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
97
src/app/maintenance-worker.ts
Normal file
97
src/app/maintenance-worker.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,3 +44,4 @@ export enum PaymentsProcessors {
|
||||
|
||||
export const EventDelegatorMetadataKey = Symbol('Delegator')
|
||||
export const EventDeduplicationMetadataKey = Symbol('Deduplication')
|
||||
export const ContextMetadataKey = Symbol('Context')
|
||||
|
4
src/constants/payments.ts
Normal file
4
src/constants/payments.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum FeeSchedules {
|
||||
ADMISSION = 'admission',
|
||||
PUBLICATION = 'publication'
|
||||
}
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
206
src/controllers/invoices/post-invoice-controller.ts
Normal file
206
src/controllers/invoices/post-invoice-controller.ts
Normal 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
|
||||
}
|
||||
}
|
37
src/database/transaction.ts
Normal file
37
src/database/transaction.ts
Normal 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()
|
||||
}
|
||||
}
|
@ -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}:*`)
|
||||
|
27
src/factories/maintenance-worker-factory.ts
Normal file
27
src/factories/maintenance-worker-factory.ts
Normal 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)
|
||||
}
|
@ -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,
|
||||
)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
24
src/factories/payments-service-factory.ts
Normal file
24
src/factories/payments-service-factory.ts
Normal 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
|
||||
)
|
||||
}
|
33
src/factories/post-invoice-controller-factory.ts
Normal file
33
src/factories/post-invoice-controller-factory.ts
Normal 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,
|
||||
)
|
||||
}
|
@ -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()
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
57
src/handlers/request-handlers/rate-limiter-middleware.ts
Normal file
57
src/handlers/request-handlers/rate-limiter-middleware.ts
Normal 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
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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),
|
||||
|
21
src/index.ts
21
src/index.ts
@ -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()
|
||||
}
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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(
|
||||
|
81
src/repositories/user-repository.ts
Normal file
81
src/repositories/user-repository.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
312
src/services/payments-service.ts
Normal file
312
src/services/payments-service.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
5
src/utils/misc.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const delayMs = (ms: number): Promise<void> => new Promise<void>(
|
||||
(resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
}
|
||||
)
|
@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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', () => {
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 })
|
||||
)
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -6,7 +6,7 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2020",
|
||||
"target": "ESNext",
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "Node",
|
||||
"types": ["node", "mocha", "@cucumber/cucumber"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user