diff --git a/.nostr.local/settings.json b/.nostr.local/settings.json index ee1d97639..add5fb4cf 100644 --- a/.nostr.local/settings.json +++ b/.nostr.local/settings.json @@ -2,18 +2,69 @@ "info": { "relay_url": "wss://nostream.localtest.me", "name": "nostream.localtest.me", - "description": "A nostr relay written in TypeScript.", - "pubkey": "replace-with-your-pubkey", + "description": "A nostr relay written in Typescript.", + "pubkey": "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", "contact": "operator@your-domain.com" }, "network": { - "max_payload_size": 131072, - "remote_ip_header": "x-forwarded-for" + "maxPayloadSize": 131072, + "remoteIpHeader": "x-forwarded-for", + "idleTimeout": 60 + }, + "payments": { + "enabled": true, + "processor": "zebedee", + "feeSchedules": { + "admission": [{ + "enabled": true, + "descripton": "Admission fee in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": ["replace-with-your-pubkey"] + } + }], + "publication": [ + { + "enabled": true, + "description": "Publication fee in msats (1000 msats = 1 satoshi)", + "amount": 100, + "whitelists": { + "pubkeys": ["replace-with-your-pubkey"] + } + } + ] + } + }, + "paymentProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.localtest.me/callbacks/zebedee" + } }, "workers": { "count": 0 }, "limits": { + "connection": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 360 + }, + { + "period": 86400000, + "rate": 2880 + } + ], + "ipWhitelist": [ + "::1", + "::ffff:10.10.10.1" + ] + }, "event": { "eventId": { "minLeadingZeroBits": 0 @@ -31,45 +82,62 @@ "maxPositiveDelta": 900, "maxNegativeDelta": 0 }, - "content": { - "maxLength": 1048576 - }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "kinds": [[0, 10], [40, 49]], + "maxLength": 65536 + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "kinds": [[11, 39], [50, 9007199254740991]], + "maxLength": 98304 + } + ], "rateLimits": [ { + "description": "6 events/min for event kinds 0, 3, 40 and 41", "kinds": [0, 3, 40, 41], "period": 60000, "rate": 6 }, { + "description": "12 events/min for event kinds 1, 2, 4 and 42", "kinds": [1, 2, 4, 42], "period": 60000, "rate": 12 }, { + "description": "360 events/hour for event kinds 1, 2, 4 and 42", "kinds": [1, 2, 4, 42], "period": 3600000, "rate": 360 }, { + "description": "30 events/min for event kind ranges 5-7 and 43-49", "kinds": [[5, 7], [43, 49]], "period": 60000, "rate": 30 }, { + "description": "24 events/min for replaceable events and parameterized replaceable events", "kinds": [[10000, 19999], [30000, 39999]], "period": 60000, "rate": 24 }, { + "description": "60 events/min for ephemeral events", "kinds": [[20000, 29999]], "period": 60000, "rate": 60 }, { + "description": "720 events/hour for all events", "period": 3600000, "rate": 720 }, { + "description": "2880 events/day for all events", "period": 86400000, "rate": 2880 } @@ -91,14 +159,29 @@ "message": { "rateLimits": [ { + "description": "60 subscriptions/min", + "types": ["REQ"], + "period": 60000, + "rate": 60 + }, + { + "description": "2880 subscriptions/hour", + "types": ["REQ"], + "period": 3600000, + "rate": 2880 + }, + { + "description": "120 raw messages/min", "period": 60000, "rate": 120 }, { + "description": "3600 raw messages/hour", "period": 3600000, "rate": 3600 }, { + "description": "86400 raw messages/day", "period": 86400000, "rate": 86400 } diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 015de4c40..7276f1dcf 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -7,6 +7,7 @@ The following environment variables can be set: | Name | Description | Default | |----------------------------------|--------------------------------|------------------------| | RELAY_PORT | Relay's server port | 8008 | +| RELAY_PRIVATE_KEY | Relay's private key in hex | (auto-generated) | | WORKER_COUNT | Number of workers override | No. of available CPUs | | DB_HOST | PostgresSQL Hostname | | | DB_PORT | PostgreSQL Port | 5432 | @@ -38,7 +39,7 @@ The following environment variables can be set: # Settings -Running `nostream` for the first time creates the settings file in `/.nostr/settings.json`. If the file is not created and an error is thrown ensure that the `/.nostr` folder exists. The configuration directory can be changed by setting the `NOSTR_CONFIG_DIR` environment variable. +Running `nostream` for the first time creates the settings file in `/.nostr/settings.yaml`. If the file is not created and an error is thrown ensure that the `/.nostr` folder exists. The configuration directory can be changed by setting the `NOSTR_CONFIG_DIR` environment variable. | Name | Description | |---------------------------------------------|-------------------------------------------------------------------------------| @@ -47,8 +48,11 @@ Running `nostream` for the first time creates the settings file in ` { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')) + table.binary('pubkey').notNullable().index() + table.text('bolt11').notNullable() + table.bigint('amount_requested').unsigned().notNullable() + table.bigint('amount_paid').unsigned() + table.enum('unit', ['msats', 'sats', 'btc']) + 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 }) + table.timestamps(true, true, false) + }) +} + +exports.down = function (knex) { + return knex.schema.dropTable('invoices') +} diff --git a/migrations/20230107_230900_create_users_table.js b/migrations/20230107_230900_create_users_table.js new file mode 100644 index 000000000..fd0d272b0 --- /dev/null +++ b/migrations/20230107_230900_create_users_table.js @@ -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') +} diff --git a/migrations/20230118_190000_confirm_invoice_func.js b/migrations/20230118_190000_confirm_invoice_func.js new file mode 100644 index 000000000..e49480657 --- /dev/null +++ b/migrations/20230118_190000_confirm_invoice_func.js @@ -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();') +} diff --git a/migrations/20230119_170900_add_kind_tags_created_at_index.js b/migrations/20230119_170900_add_kind_tags_created_at_index.js new file mode 100644 index 000000000..0eab0dc15 --- /dev/null +++ b/migrations/20230119_170900_add_kind_tags_created_at_index.js @@ -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;') +} diff --git a/migrations/20230120_161800_charge_user_func.js b/migrations/20230120_161800_charge_user_func.js new file mode 100644 index 000000000..b75cecb4e --- /dev/null +++ b/migrations/20230120_161800_charge_user_func.js @@ -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);') +} diff --git a/migrations/20230126_230000_add_remote_address_to_events_table.js b/migrations/20230126_230000_add_remote_address_to_events_table.js new file mode 100644 index 000000000..48fbcfd27 --- /dev/null +++ b/migrations/20230126_230000_add_remote_address_to_events_table.js @@ -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') + }) +} diff --git a/package-lock.json b/package-lock.json index 119a3eebe..306f58548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,17 @@ "version": "1.18.0", "license": "MIT", "dependencies": { - "@noble/secp256k1": "^1.7.1", + "@noble/secp256k1": "1.7.1", + "axios": "1.2.2", + "bech32": "2.0.0", + "body-parser": "1.20.1", "debug": "4.3.4", - "dotenv": "^16.0.3", + "dinero.js": "2.0.0-alpha.13", + "dotenv": "16.0.3", + "express": "4.18.2", + "helmet": "6.0.1", "joi": "17.7.0", + "js-yaml": "4.1.0", "knex": "2.4.0", "pg": "8.8.0", "pg-query-stream": "4.2.4", @@ -20,7 +27,7 @@ "redis": "4.5.1", "rxjs": "7.8.0", "tor-control-ts": "^1.0.0", - "ws": "^8.12.0" + "ws": "8.12.0" }, "devDependencies": { "@commitlint/cli": "17.2.0", @@ -35,8 +42,10 @@ "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.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", @@ -59,9 +68,9 @@ "sinon": "15.0.1", "sinon-chai": "^3.7.0", "source-map-support": "^0.5.21", - "ts-node": "^10.9.1", + "ts-node": "10.9.1", "ts-node-dev": "^1.1.8", - "typescript": "4.6", + "typescript": "4.6.4", "uuid": "^8.3.2" } }, @@ -1064,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", @@ -1767,6 +1797,16 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.1.tgz", @@ -1782,6 +1822,15 @@ "@types/chai": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -1791,6 +1840,35 @@ "@types/ms": "*" } }, + "node_modules/@types/express": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.15.tgz", + "integrity": "sha512-Yv0k4bXGOH+8a+7bELd2PqHQsuiANB+A8a4gnQrkRWzrkKlb6KHaVvyXhqs04sVW/OWlbPyYxRgYlIXLfrufMQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.31", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.32", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.32.tgz", + "integrity": "sha512-aI5h/VOkxOF2Z1saPy0Zsxs5avets/iaiAJYznQFm5By/pamU31xWKL//epiF4OfUA2qTOc9PV6tCUjhO8wlZA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", + "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -1812,6 +1890,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", + "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==", + "dev": true + }, "node_modules/@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -1831,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": { @@ -1859,6 +1943,12 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, "node_modules/@types/ramda": { "version": "0.28.13", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.28.13.tgz", @@ -1868,12 +1958,28 @@ "ts-toolbelt": "^6.15.1" } }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "dev": true }, + "node_modules/@types/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==", + "dev": true, + "dependencies": { + "@types/mime": "*", + "@types/node": "*" + } + }, "node_modules/@types/sinon": { "version": "10.0.11", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.11.tgz", @@ -2122,6 +2228,18 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", @@ -2358,8 +2476,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/argv-formatter": { "version": "1.0.0", @@ -2367,6 +2484,11 @@ "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", "dev": true }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -2429,8 +2551,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -2442,12 +2563,13 @@ } }, "node_modules/axios": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", - "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", - "dev": true, + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz", + "integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==", "dependencies": { - "follow-redirects": "^1.14.7" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "node_modules/bail": { @@ -2481,6 +2603,15 @@ "node": ">=10" } }, + "node_modules/base-api-client/node_modules/axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.7" + } + }, "node_modules/base-api-client/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2507,6 +2638,11 @@ } ] }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -2547,6 +2683,42 @@ "node": ">= 6" } }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -2656,6 +2828,14 @@ "node": ">=4" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cachedir": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", @@ -2680,6 +2860,18 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3021,7 +3213,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3162,6 +3353,25 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-angular": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", @@ -3307,6 +3517,19 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -3713,17 +3936,33 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", @@ -3751,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", @@ -3832,6 +4081,11 @@ "xtend": "^4.0.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "node_modules/electron-to-chromium": { "version": "1.4.218", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.218.tgz", @@ -3844,6 +4098,14 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -3929,8 +4191,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -4162,6 +4423,14 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4197,6 +4466,65 @@ "node": ">=0.10.0" } }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -4356,6 +4684,36 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -4467,7 +4825,6 @@ "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true, "funding": [ { "type": "individual", @@ -4500,7 +4857,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4510,6 +4866,22 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -4632,6 +5004,19 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -4967,6 +5352,17 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -5001,6 +5397,14 @@ "he": "bin/he" } }, + "node_modules/helmet": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.0.1.tgz", + "integrity": "sha512-8wo+VdQhTMVBMCITYZaGTbE4lvlthelPYSvoyNvk4RECTmrVjMerp9RfUOQXZWLvCcAn1pKj7ZRxK4lI9Alrcw==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -5040,6 +5444,21 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -5095,7 +5514,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -5191,8 +5609,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "2.0.0", @@ -5280,6 +5697,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", @@ -5713,7 +6138,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6273,6 +6697,14 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -6316,6 +6748,11 @@ "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", "dev": true }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6331,6 +6768,14 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromark": { "version": "2.11.4", "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", @@ -6380,7 +6825,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6389,7 +6833,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6720,6 +7163,14 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -9623,6 +10074,25 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9927,6 +10397,14 @@ "parse-path": "^7.0.0" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10341,6 +10819,23 @@ "integrity": "sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==", "dev": true }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -10360,6 +10855,20 @@ "teleport": ">=0.2.0" } }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10407,6 +10916,28 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -10996,7 +11527,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -11015,8 +11545,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/seed-random": { "version": "2.2.0", @@ -11158,6 +11687,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -11167,12 +11748,31 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11194,6 +11794,19 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -11449,6 +12062,14 @@ "integrity": "sha512-GGrHXePi305aW7XQweYZZwiRwR7Js3MWoK/EHzzB9ROdc75nCnjSJVi21rdAGxFl+yCx2L2qdfl5y7NO4lTyqg==", "dev": true }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -11800,6 +12421,14 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", @@ -12065,6 +12694,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -12158,6 +12799,14 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz", @@ -12226,6 +12875,14 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -12266,6 +12923,14 @@ "node": ">= 0.10" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -13370,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", @@ -13928,6 +14614,16 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/chai": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.1.tgz", @@ -13943,6 +14639,15 @@ "@types/chai": "*" } }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -13952,6 +14657,35 @@ "@types/ms": "*" } }, + "@types/express": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.15.tgz", + "integrity": "sha512-Yv0k4bXGOH+8a+7bELd2PqHQsuiANB+A8a4gnQrkRWzrkKlb6KHaVvyXhqs04sVW/OWlbPyYxRgYlIXLfrufMQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.31", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.32", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.32.tgz", + "integrity": "sha512-aI5h/VOkxOF2Z1saPy0Zsxs5avets/iaiAJYznQFm5By/pamU31xWKL//epiF4OfUA2qTOc9PV6tCUjhO8wlZA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/js-yaml": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", + "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "dev": true + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -13973,6 +14707,12 @@ "@types/unist": "*" } }, + "@types/mime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", + "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==", + "dev": true + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -13992,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": { @@ -14020,6 +14760,12 @@ "pg-types": "^2.2.0" } }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, "@types/ramda": { "version": "0.28.13", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.28.13.tgz", @@ -14029,12 +14775,28 @@ "ts-toolbelt": "^6.15.1" } }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "dev": true }, + "@types/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==", + "dev": true, + "requires": { + "@types/mime": "*", + "@types/node": "*" + } + }, "@types/sinon": { "version": "10.0.11", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.11.tgz", @@ -14194,6 +14956,15 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, "acorn": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", @@ -14374,8 +15145,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "argv-formatter": { "version": "1.0.0", @@ -14383,6 +15153,11 @@ "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", "dev": true }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -14433,8 +15208,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "at-least-node": { "version": "1.0.0", @@ -14443,12 +15217,13 @@ "dev": true }, "axios": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", - "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", - "dev": true, + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz", + "integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==", "requires": { - "follow-redirects": "^1.14.7" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "bail": { @@ -14475,6 +15250,15 @@ "uuid": "8.3.2" }, "dependencies": { + "axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "dev": true, + "requires": { + "follow-redirects": "^1.14.7" + } + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14489,6 +15273,11 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, + "bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, "before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -14525,6 +15314,40 @@ } } }, + "body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -14595,6 +15418,11 @@ "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "cachedir": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", @@ -14613,6 +15441,15 @@ "write-file-atomic": "^3.0.0" } }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -14859,7 +15696,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -14972,6 +15808,19 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, "conventional-changelog-angular": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", @@ -15093,6 +15942,16 @@ } } }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -15396,8 +16255,12 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, "deprecation": { "version": "2.3.1", @@ -15405,6 +16268,11 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, "detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", @@ -15423,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", @@ -15489,6 +16367,11 @@ "xtend": "^4.0.0" } }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "electron-to-chromium": { "version": "1.4.218", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.218.tgz", @@ -15501,6 +16384,11 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -15576,8 +16464,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "4.0.0", @@ -15747,6 +16634,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, "execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -15773,6 +16665,64 @@ "homedir-polyfill": "^1.0.1" } }, + "express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + } + } + }, "ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -15910,6 +16860,35 @@ "to-regex-range": "^5.0.1" } }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -15993,8 +16972,7 @@ "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, "foreground-child": { "version": "2.0.0", @@ -16010,13 +16988,22 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, "from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -16103,6 +17090,16 @@ "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", "dev": true }, + "get-intrinsic": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + } + }, "get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -16366,6 +17363,11 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, "hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -16390,6 +17392,11 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "helmet": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.0.1.tgz", + "integrity": "sha512-8wo+VdQhTMVBMCITYZaGTbE4lvlthelPYSvoyNvk4RECTmrVjMerp9RfUOQXZWLvCcAn1pKj7ZRxK4lI9Alrcw==" + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -16420,6 +17427,18 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -16457,7 +17476,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -16515,8 +17533,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "2.0.0", @@ -16579,6 +17596,11 @@ "p-is-promise": "^3.0.0" } }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "is-alphabetical": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", @@ -16891,7 +17913,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" } @@ -17326,6 +18347,11 @@ "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", "dev": true }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, "meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -17359,6 +18385,11 @@ "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", "dev": true }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -17371,6 +18402,11 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, "micromark": { "version": "2.11.4", "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", @@ -17400,14 +18436,12 @@ "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "requires": { "mime-db": "1.52.0" } @@ -17666,6 +18700,11 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, "neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -19725,6 +20764,19 @@ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true }, + "object-inspect": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -19949,6 +21001,11 @@ "parse-path": "^7.0.0" } }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -20262,6 +21319,20 @@ "integrity": "sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==", "dev": true }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -20274,6 +21345,14 @@ "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", "dev": true }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -20300,6 +21379,22 @@ "safe-buffer": "^5.1.0" } }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -20765,14 +21860,12 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "seed-random": { "version": "2.2.0", @@ -20887,6 +21980,53 @@ "integrity": "sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA==", "dev": true }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -20896,12 +22036,28 @@ "randombytes": "^2.1.0" } }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -20917,6 +22073,16 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -21130,6 +22296,11 @@ "integrity": "sha512-GGrHXePi305aW7XQweYZZwiRwR7Js3MWoK/EHzzB9ROdc75nCnjSJVi21rdAGxFl+yCx2L2qdfl5y7NO4lTyqg==", "dev": true }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, "stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -21405,6 +22576,11 @@ "is-number": "^7.0.0" } }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", @@ -21584,6 +22760,15 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -21650,6 +22835,11 @@ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", "dev": true }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "update-browserslist-db": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz", @@ -21704,6 +22894,11 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -21738,6 +22933,11 @@ "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", "dev": true }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, "verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", diff --git a/package.json b/package.json index 0bd852044..e6249048e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "prestart": "npm run build", "start": "cd dist && node src/index.js", "build:check": "npm run build -- --noEmit", - "lint": "eslint -o .lint-reports/eslint.json -f json --ext .ts ./src ./test", + "lint": "eslint --ext .ts ./src ./test", + "lint:report": "eslint -o .lint-reports/eslint.json -f json --ext .ts ./src ./test", "lint:fix": "npm run lint -- --fix", "db:migrate": "knex migrate:latest", "db:migrate:rollback": "knex migrate:rollback", @@ -78,8 +79,10 @@ "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.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", @@ -102,24 +105,31 @@ "sinon": "15.0.1", "sinon-chai": "^3.7.0", "source-map-support": "^0.5.21", - "ts-node": "^10.9.1", + "ts-node": "10.9.1", "ts-node-dev": "^1.1.8", - "typescript": "4.6", + "typescript": "4.6.4", "uuid": "^8.3.2" }, "dependencies": { "@noble/secp256k1": "1.7.1", + "axios": "1.2.2", + "bech32": "2.0.0", + "body-parser": "1.20.1", "debug": "4.3.4", - "dotenv": "^16.0.3", + "dinero.js": "2.0.0-alpha.13", + "dotenv": "16.0.3", + "express": "4.18.2", + "helmet": "6.0.1", "joi": "17.7.0", + "js-yaml": "4.1.0", "knex": "2.4.0", "pg": "8.8.0", "pg-query-stream": "4.2.4", "ramda": "0.28.0", "redis": "4.5.1", - "tor-control-ts": "^1.0.0", "rxjs": "7.8.0", - "ws": "8.12.0" + "ws": "8.12.0", + "tor-control-ts": "^1.0.0" }, "config": { "commitizen": { diff --git a/resources/css/style.css b/resources/css/style.css new file mode 100644 index 000000000..1ec11f2df --- /dev/null +++ b/resources/css/style.css @@ -0,0 +1,178 @@ +:root { + --bs-warning: #f7931a; +} +.m-auto { + margin: auto !important; +} +.w-100 { + width: 100% !important; +} +.w-66 { + width: 66% !important; +} +.btn-warning { + background-color: #f7931a; +} +.hidden { + display: none; +} +#invoice img { + width: 100%; +} +.mw-256 { + max-width: 256px; +} +.success-checkmark { + width: 80px; + height: 115px; + margin: 0 auto; +} +.success-checkmark .check-icon { + width: 80px; + height: 80px; + position: relative; + border-radius: 50%; + box-sizing: content-box; + border: 4px solid #4CAF50; +} +.success-checkmark .check-icon::before { + top: 3px; + left: -2px; + width: 30px; + transform-origin: 100% 50%; + border-radius: 100px 0 0 100px; +} +.success-checkmark .check-icon::after { + top: 0; + left: 30px; + width: 60px; + transform-origin: 0 50%; + border-radius: 0 100px 100px 0; + animation: rotate-circle 10.25s ease-in; +} +.success-checkmark .check-icon::before, .success-checkmark .check-icon::after { + content: ""; + height: 100px; + position: absolute; + background: #FFFFFF; + transform: rotate(-45deg); +} +.success-checkmark .check-icon .icon-line { + height: 5px; + background-color: #4CAF50; + display: block; + border-radius: 2px; + position: absolute; + z-index: 10; +} +.success-checkmark .check-icon .icon-line.line-tip { + top: 46px; + left: 14px; + width: 25px; + transform: rotate(45deg); + animation: icon-line-tip 2s; +} +.success-checkmark .check-icon .icon-line.line-long { + top: 38px; + right: 8px; + width: 47px; + transform: rotate(-45deg); + animation: icon-line-long 2s; +} +.success-checkmark .check-icon .icon-circle { + top: -4px; + left: -4px; + z-index: 10; + width: 80px; + height: 80px; + border-radius: 50%; + position: absolute; + box-sizing: content-box; + border: 4px solid rgba(76, 175, 80, 0.5); +} +.success-checkmark .check-icon .icon-fix { + top: 8px; + width: 5px; + left: 26px; + z-index: 1; + height: 85px; + position: absolute; + transform: rotate(-45deg); + background-color: #FFFFFF; +} +.description-text { + max-width: 400px; +} +.poweredbyzbd-img { + width: 150px; + transition: 0.15s all ease-in-out; +} +.poweredbyzbd-img:hover { + cursor: pointer; + transform: scale(1.05); + transition: 0.15s all ease-in-out; +} + +@keyframes rotate-circle { + 0% { + transform: rotate(-45deg); + } + 5% { + transform: rotate(-45deg); + } + 12% { + transform: rotate(-405deg); + } + 100% { + transform: rotate(-405deg); + } +} +@keyframes icon-line-tip { + 0% { + width: 0; + left: 1px; + top: 19px; + } + 54% { + width: 0; + left: 1px; + top: 19px; + } + 70% { + width: 50px; + left: -8px; + top: 37px; + } + 84% { + width: 17px; + left: 21px; + top: 48px; + } + 100% { + width: 25px; + left: 14px; + top: 45px; + } +} +@keyframes icon-line-long { + 0% { + width: 0; + right: 46px; + top: 54px; + } + 65% { + width: 0; + right: 46px; + top: 54px; + } + 84% { + width: 55px; + right: 0px; + top: 35px; + } + 100% { + width: 47px; + right: 8px; + top: 38px; + } +} \ No newline at end of file diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml new file mode 100755 index 000000000..3ef3b33d4 --- /dev/null +++ b/resources/default-settings.yaml @@ -0,0 +1,184 @@ +info: + relay_url: wss://nostream.your-domain.com + name: nostream.your-domain.com + description: A nostr relay written in Typescript. + pubkey: replace-with-your-pubkey-in-hex + contact: operator@your-domain.com +payments: + enabled: false + processor: zebedee + feeSchedules: + admission: + - enabled: false + descripton: Admission fee charged per public key in msats (1000 msats = 1 satoshi) + amount: 1000000 + whitelists: + pubkeys: + - replace-with-your-pubkey-in-hex + publication: + - enabled: false + description: Publication fee charged per event in msats (1000 msats = 1 satoshi) + amount: 10 + whitelists: + pubkeys: + - replace-with-your-pubkey-in-hex +paymentsProcessors: + zebedee: + baseURL: https://api.zebedee.io/ + callbackBaseURL: https://nostream.your-domain.com/callbacks/zebedee + ipWhitelist: + - "3.225.112.64" + - "::ffff:3.225.112.64" +network: + maxPayloadSize: 131072 + remoteIpHeader: x-forwarded-for + idleTimeout: 60 +workers: + count: 0 +mirroring: + static: [] +limits: + invoice: + rateLimits: + - period: 60000 + rate: 3 + - period: 3600000 + rate: 10 + - period: 86400000 + rate: 20 + ipWhitelist: + - "::1" + - "10.10.10.1" + - "::ffff:10.10.10.1" + connection: + rateLimits: + - period: 1000 + rate: 6 + - period: 60000 + rate: 30 + - period: 3600000 + rate: 300 + - period: 86400000 + rate: 1440 + ipWhitelist: + - "::1" + - "10.10.10.1" + - "::ffff:10.10.10.1" + event: + eventId: + minLeadingZeroBits: 0 + kind: + whitelist: [] + blacklist: [] + pubkey: + minBalance: 0 + minLeadingZeroBits: 0 + whitelist: [] + blacklist: [] + createdAt: + maxPositiveDelta: 900 + maxNegativeDelta: 0 + content: + - description: 64 KB for event kind ranges 0-10 and 40-49 + kinds: + - - 0 + - 10 + - - 40 + - 49 + maxLength: 65536 + - description: 96 KB for event kind ranges 11-39 and 50-max + kinds: + - - 11 + - 39 + - - 50 + - 9007199254740991 + maxLength: 98304 + rateLimits: + - description: 6 events/min for event kinds 0, 3, 40 and 41 + kinds: + - 0 + - 3 + - 40 + - 41 + period: 60000 + rate: 6 + - description: 12 events/min for event kinds 1, 2, 4 and 42 + kinds: + - 1 + - 2 + - 4 + - 42 + period: 60000 + rate: 12 + - description: 360 events/hour for event kinds 1, 2, 4 and 42 + kinds: + - 1 + - 2 + - 4 + - 42 + period: 3600000 + rate: 360 + - description: 30 events/min for event kind ranges 5-7 and 43-49 + kinds: + - - 5 + - 7 + - - 43 + - 49 + period: 60000 + rate: 30 + - description: 24 events/min for replaceable events and parameterized replaceable + events + kinds: + - - 10000 + - 19999 + - - 30000 + - 39999 + period: 60000 + rate: 24 + - description: 60 events/min for ephemeral events + kinds: + - - 20000 + - 29999 + period: 60000 + rate: 60 + - description: 720 events/hour for all events + period: 3600000 + rate: 720 + - description: 2880 events/day for all events + period: 86400000 + rate: 2880 + whitelists: + pubkeys: [] + ipAddresses: + - "::1" + - "10.10.10.1" + - "::ffff:10.10.10.1" + client: + subscription: + maxSubscriptions: 10 + maxFilters: 10 + message: + rateLimits: + # - description: 60 subscriptions/min + # types: + # - REQ + # period: 60000 + # rate: 60 + # - description: 2880 subscriptions/hour + # types: + # - REQ + # period: 3600000 + # rate: 2880 + - description: 120 raw messages/min + period: 60000 + rate: 120 + - description: 3600 raw messages/hour + period: 3600000 + rate: 3600 + - description: 86400 raw messages/day + period: 86400000 + rate: 86400 + ipWhitelist: + - "::1" + - "10.10.10.1" + - "::ffff:10.10.10.1" diff --git a/resources/favicon.ico b/resources/favicon.ico new file mode 100644 index 000000000..e21d60ebc Binary files /dev/null and b/resources/favicon.ico differ diff --git a/resources/index.html b/resources/index.html new file mode 100644 index 000000000..d6bef0e7a --- /dev/null +++ b/resources/index.html @@ -0,0 +1,153 @@ + + + + + + Admission Fee Required - {{name}} + + + + +
+
+
+
+

{{name}}

+
+
+
+
+
+
+

+ This Nostr relay requires a one-time + admission + fee.
Once payment is complete, you will be able to post and fetch events from this relay. +

+
+
+

+ Provide your Nostr public key to generate a Bitcoin Lightning invoice. +

+
+
+
+
+
+
+
+
+ + +
+ Hex or npub formats accepted. +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
+ + + + + diff --git a/resources/invoices.html b/resources/invoices.html new file mode 100644 index 000000000..d2d29ecfa --- /dev/null +++ b/resources/invoices.html @@ -0,0 +1,216 @@ + + + + + + Invoice Payment - {{name}} + + + + + +
+
+
+
+

{{name}}

+
+
+
+
+

+ Scan with your preferred Bitcoin Lightning wallet: +

+ +

+ Your invoice expired. Try again! +

+
+
+
+
+
+
+
+
+
+ + copy +
+
+
+
+
+ Waiting for payment... +
+
+
+ +
+
+

Invoice expired!

+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+ + + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/resources/terms.html b/resources/terms.html new file mode 100644 index 000000000..587ba5524 --- /dev/null +++ b/resources/terms.html @@ -0,0 +1,66 @@ + + + + + + Terms of Service Agreement - {{name}} + + + + +
+

{{name}}

+
+
+

Terms of Service Agreement

+

+ These are the terms of service for {{name}}; please read them before using {{name}}. +

+

+ This service (and supporting services) are 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. +

+

+ By connecting to this relay, you agree: +

    +
  • To not engage in spam
  • +
  • To not flood
  • +
  • To not expect content moderation
  • +
  • To not misuse or abuse the relay service and other supporting services
  • +
  • To not disseminate illegal content or material
  • +
  • That requests to delete content you published cannot be guaranteed
  • +
  • That this relay has no control over any content published in other relays
  • +
  • That some services, such as but not limited to the privilege to publish content may require payment(s)
  • +
  • That charge backs from payments may result in the termination of the privilege to use the service
  • +
  • That the service might be revoked to you at the operator's sole discretion if found in violation of these terms
  • +
  • That the terms of service may change at any time in the future without explicit notice
  • +
  • To grant us the necessary rights to your content to provide the service to you and to other users for an unlimited time
  • +
  • To use the service in compliance with all laws, rules, and regulations applicable to you
  • +
  • To use the service in good faith and not seek to get the relay operator(s) in trouble
  • +
  • That the service may throttle, rate limit or revoke your access to any content and/or your privilege to publish content for any reason
  • +
  • That the content you publish to this relay will be further broadcasted to any interested client and/or accepting relay
  • +
  • To not infringe on the right of others to publish content to this relay as allowed by the terms of service
  • +
  • That this service is not targeted, nor intended for use by, anyone under the legal age in their respective jurisdiction
  • +
  • To be of legal age or have sufficient legal constent, permission and capacity to use this service
  • +
  • That the service may be temporarily shutdown or permanently terminated at any time and without notice
  • +
  • That the content published by you and other users may be removed at any time and without notice and for any reason
  • +
  • To have your IP address and/or public key collected for the purpose of detecting abuse, spam or misuse
  • +
  • To have your IP address and/or public key in full, truncated, or as a hash digest shared with interested clients and other accepting relays for the sole purpose of reporting abuse, spam or misuse
  • +
  • To cooperate with the relay and its operators for the purpose of combating abuse, spam or misuse of the service
  • +
+
+ In addition you understand that: +
    +
  • Nostr is a decentralized and distributed network of relays that relays data by users.
  • +
  • Censorship resistance is practiced by posting to multiple relays and running your own private relay.
  • +
  • The responsibility of filtering and moderating is the sole responsibility of the users and not of the relays.
  • +
  • You may be inadvertently exposed to content that you might find triggering, disturbing, distasteful, immoral or against your views.
  • +
  • The relay operator is not liable and has no involvement in the type, quality and legality of the content being produced by users of the relay.
  • +
+

+
+
+
+ + + diff --git a/scripts/clean b/scripts/clean index f70c71aa9..3e8db0987 100755 --- a/scripts/clean +++ b/scripts/clean @@ -3,6 +3,6 @@ PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." $PROJECT_ROOT/scripts/stop_docker -docker system prune +docker system prune -a docker volume prune diff --git a/scripts/start b/scripts/start index f9c01653b..10eee12d3 100755 --- a/scripts/start +++ b/scripts/start @@ -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 \ diff --git a/scripts/start_with_tor b/scripts/start_with_tor index cee87d0ff..581734abd 100755 --- a/scripts/start_with_tor +++ b/scripts/start_with_tor @@ -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 diff --git a/scripts/stop b/scripts/stop index 38eeb6564..7122edf31 100755 --- a/scripts/stop +++ b/scripts/stop @@ -2,8 +2,10 @@ 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" +DOCKER_COMPOSE_LOCAL_FILE="${PROJECT_ROOT}/docker-compose.local.yml" docker compose \ -f $DOCKER_COMPOSE_FILE \ -f $DOCKER_COMPOSE_TOR_FILE \ + -f $DOCKER_COMPOSE_LOCAL_FILE \ down $@ diff --git a/scripts/update b/scripts/update new file mode 100755 index 000000000..54fe76fdb --- /dev/null +++ b/scripts/update @@ -0,0 +1,13 @@ +#!/bin/bash +PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." +SCRIPTS_DIR="${PROJECT_ROOT}/scripts" + +$SCRIPTS_DIR/stop + +git stash -u + +git pull + +git stash pop + +$SCRIPTS_DIR/start diff --git a/settings.sample.json b/settings.sample.json deleted file mode 100644 index 87b659416..000000000 --- a/settings.sample.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "info": { - "relay_url": "wss://nostream.your-domain.com", - "name": "nostream.your-domain.com", - "description": "A nostr relay written in TypeScript.", - "pubkey": "replace-with-your-pubkey", - "contact": "operator@your-domain.com" - }, - "network": { - "max_payload_size": 131072, - "remote_ip_header": "x-forwarded-for" - }, - "workers": { - "count": 0 - }, - "limits": { - "event": { - "eventId": { - "minLeadingZeroBits": 0 - }, - "kind": { - "whitelist": [], - "blacklist": [] - }, - "pubkey": { - "minLeadingZeroBits": 0, - "whitelist": [], - "blacklist": [] - }, - "createdAt": { - "maxPositiveDelta": 900, - "maxNegativeDelta": 0 - }, - "content": { - "maxLength": 1048576 - }, - "rateLimits": [ - { - "kinds": [0, 3, 40, 41], - "period": 60000, - "rate": 6 - }, - { - "kinds": [1, 2, 4, 42], - "period": 60000, - "rate": 12 - }, - { - "kinds": [1, 2, 4, 42], - "period": 3600000, - "rate": 360 - }, - { - "kinds": [[5, 7], [43, 49]], - "period": 60000, - "rate": 30 - }, - { - "kinds": [[10000, 19999], [30000, 39999]], - "period": 60000, - "rate": 24 - }, - { - "kinds": [[20000, 29999]], - "period": 60000, - "rate": 60 - }, - { - "period": 3600000, - "rate": 720 - }, - { - "period": 86400000, - "rate": 2880 - } - ], - "whitelists": { - "pubkeys": [], - "ipAddresses": [ - "::1", - "::ffff:10.10.10.1" - ] - } - }, - "client": { - "subscription": { - "maxSubscriptions": 10, - "maxFilters": 10 - } - }, - "message": { - "rateLimits": [ - { - "period": 60000, - "rate": 120 - }, - { - "period": 3600000, - "rate": 3600 - }, - { - "period": 86400000, - "rate": 86400 - } - ], - "ipWhitelist": [ - "::1", - "::ffff:10.10.10.1" - ] - } - } -} \ No newline at end of file diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index d570b52ae..0e491c3a7 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -3,11 +3,12 @@ import { SubscriptionFilter } from './subscription' export interface IWebSocketServerAdapter extends EventEmitter, IWebServerAdapter { getConnectedClients(): number - close(callback: () => void): void + close(callback?: () => void): void } export interface IWebServerAdapter extends EventEmitter { listen(port: number): void + close(callback?: () => void): void } @@ -18,6 +19,9 @@ export type IWebSocketAdapter = EventEmitter & { } export interface ICacheAdapter { + getKey(key: string): Promise + hasKey(key: string): Promise + setKey(key: string, value: string): Promise addToSortedSet(key: string, set: Record | Record[]): Promise removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise diff --git a/src/@types/base.ts b/src/@types/base.ts index 16e23cd1f..7065832ba 100644 --- a/src/@types/base.ts +++ b/src/@types/base.ts @@ -1,4 +1,5 @@ import { Knex } from 'knex' +import { SocketAddress } from 'net' export type EventId = string export type Pubkey = string @@ -6,6 +7,8 @@ export type TagName = string export type Signature = string export type Tag = TagBase & string[] +export type Secret = string + export interface TagBase { 0: TagName [index: number]: string @@ -27,6 +30,13 @@ export type Factory = (input: TInput) => TOutput export type DatabaseClient = Knex +export type DatabaseTransaction = any> = Knex.Transaction + +export interface ContextMetadata { + remoteAddress: SocketAddress +} + export interface IRunnable { run(): void + close(callback?: (...args: any[]) => void): void } \ No newline at end of file diff --git a/src/@types/clients.ts b/src/@types/clients.ts new file mode 100644 index 000000000..00fbc1a12 --- /dev/null +++ b/src/@types/clients.ts @@ -0,0 +1,28 @@ +import { Invoice, InvoiceStatus, InvoiceUnit } from './invoice' + +export interface CreateInvoiceResponse { + 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 { + amount: bigint + description?: string + requestId?: string +} + +export type GetInvoiceResponse = Invoice + +export interface IPaymentsProcessor { + createInvoice(request: CreateInvoiceRequest): Promise + getInvoice(invoiceId: string): Promise +} diff --git a/src/@types/controllers.ts b/src/@types/controllers.ts new file mode 100644 index 000000000..3820ba9ca --- /dev/null +++ b/src/@types/controllers.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express' + +export interface IController { + handleRequest(request: Request, response: Response): Promise +} diff --git a/src/@types/database.ts b/src/@types/database.ts new file mode 100644 index 000000000..234d55afa --- /dev/null +++ b/src/@types/database.ts @@ -0,0 +1,8 @@ +import { DatabaseTransaction } from './base' + +export interface ITransaction { + begin(): Promise + get transaction (): DatabaseTransaction + commit(): Promise + rollback(): Promise +} diff --git a/src/@types/event.ts b/src/@types/event.ts index f3183e886..e971d8bfd 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -1,8 +1,7 @@ -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 { +export interface BaseEvent { id: EventId pubkey: Pubkey created_at: number @@ -12,6 +11,16 @@ export interface Event { content: string } +export interface Event extends BaseEvent { + [ContextMetadataKey]?: ContextMetadata +} + +export type RelayedEvent = Event + +export type UnsignedEvent = Omit + +export type UnidentifiedEvent = Omit + export interface DelegatedEvent extends Event { [EventDelegatorMetadataKey]?: Pubkey } diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts new file mode 100644 index 000000000..112ab71cb --- /dev/null +++ b/src/@types/invoice.ts @@ -0,0 +1,42 @@ +import { Pubkey } from './base' + +export enum InvoiceUnit { + MSATS = 'msats', + SATS = 'sats', + BTC = 'btc' +} + +export enum InvoiceStatus { + PENDING = 'pending', + COMPLETED = 'completed' +} + +export interface Invoice { + id: string + pubkey: Pubkey + bolt11: string + amountRequested: bigint + amountPaid?: bigint + unit: InvoiceUnit + status: InvoiceStatus + description: string + confirmedAt?: Date | null + expiresAt: Date | null + updatedAt: Date + createdAt: Date +} + +export interface DBInvoice { + id: string + pubkey: Buffer + bolt11: string + amount_requested: BigInt + amount_paid: BigInt + unit: InvoiceUnit + status: InvoiceStatus, + description: string + confirmed_at: Date + expires_at: Date + updated_at: Date + created_at: Date +} diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 49a818992..63f24b625 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -1,6 +1,7 @@ -import { EventId, Range } from './base' +import { ContextMetadata, EventId, Range, Secret } from './base' +import { Event, RelayedEvent } from './event' import { SubscriptionFilter, SubscriptionId } from './subscription' -import { Event } from './event' +import { ContextMetadataKey } from '../constants/base' export enum MessageType { REQ = 'REQ', @@ -11,10 +12,13 @@ export enum MessageType { OK = 'OK' } -export type IncomingMessage = +export type IncomingMessage = ( | SubscribeMessage | IncomingEventMessage | UnsubscribeMessage + ) & { + [ContextMetadataKey]?: ContextMetadata + } export type OutgoingMessage = @@ -32,9 +36,12 @@ export type SubscribeMessage = { export type IncomingEventMessage = EventMessage & [MessageType.EVENT, Event] +export type IncomingRelayedEventMessage = [MessageType.EVENT, RelayedEvent, Secret] + export interface EventMessage { 0: MessageType.EVENT 1: Event + 2?: Secret } export interface OutgoingEventMessage { diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 6eefc06cb..2470bff23 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -1,8 +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' @@ -17,3 +19,25 @@ export interface IEventRepository { insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise } + +export interface IInvoiceRepository { + findById(id: string, client?: DatabaseClient): Promise + upsert(invoice: Partial, client?: DatabaseClient): Promise + confirmInvoice( + invoiceId: string, + amountReceived: bigint, + confirmedAt: Date, + client?: DatabaseClient, + ): Promise + findPendingInvoices( + offset?: number, + limit?: number, + client?: DatabaseClient, + ): Promise +} + +export interface IUserRepository { + findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise + upsert(user: Partial, client?: DatabaseClient): Promise + getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise +} diff --git a/src/@types/services.ts b/src/@types/services.ts new file mode 100644 index 000000000..4d3fb5d2b --- /dev/null +++ b/src/@types/services.ts @@ -0,0 +1,18 @@ +import { Invoice } from './invoice' +import { Pubkey } from './base' + +export interface IPaymentsService { + getInvoiceFromPaymentsProcessor(invoiceId: string): Promise + createInvoice( + pubkey: Pubkey, + amount: bigint, + description: string, + ): Promise + updateInvoice(invoice: Invoice): Promise + confirmInvoice( + invoice: Pick, + ): Promise + sendNewInvoiceNotification(invoice: Invoice): Promise + sendInvoiceUpdateNotification(invoice: Invoice): Promise + getPendingInvoices(): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 7e698c2a4..818e5961e 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -1,17 +1,25 @@ +import { Pubkey, Secret } from './base' import { EventKinds } from '../constants/base' -import { Pubkey } from './base' +import { MessageType } from './messages' +import { SubscriptionFilter } from './subscription' 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 { - max_payload_size?: number - remote_ip_header?: string + maxPayloadSize?: number + remoteIpHeader?: string +} + +export interface RateLimit { + description?: string + period: number + rate: number } export interface EventIdLimits { @@ -19,6 +27,7 @@ export interface EventIdLimits { } export interface PubkeyLimits { + minBalance: bigint minLeadingZeroBits: number whitelist?: Pubkey[] blacklist?: Pubkey[] @@ -26,10 +35,8 @@ export interface PubkeyLimits { export type EventKindsRange = [EventKinds, EventKinds] -export interface EventRateLimit { +export interface EventRateLimit extends RateLimit { kinds?: (EventKinds | [EventKinds, EventKinds])[] - rate: number - period: number } export interface KindLimits { @@ -49,6 +56,11 @@ export interface CreatedAtLimits { } export interface ContentLimits { + description?: string + kinds?: (EventKinds | EventKindsRange)[] + /** + * Maximum number of characters allowed on events + */ maxLength?: number } @@ -62,7 +74,7 @@ export interface EventLimits { pubkey?: PubkeyLimits kind?: KindLimits createdAt?: CreatedAtLimits - content?: ContentLimits + content?: ContentLimits | ContentLimits[] rateLimits?: EventRateLimit[] whitelists?: EventWhitelists } @@ -76,9 +88,8 @@ export interface ClientLimits { subscription?: ClientSubscriptionLimits } -export interface MessageRateLimit { - rate: number - period: number +export interface MessageRateLimit extends RateLimit { + type?: MessageType } export interface MessageLimits { @@ -86,7 +97,20 @@ export interface MessageLimits { ipWhitelist?: string[] } +export interface ConnectionLimits { + rateLimits: RateLimit[] + ipWhitelist?: string[] + ipBlacklist?: string[] +} + +export interface InvoiceLimits { + rateLimits: RateLimit[] + ipWhitelist?: string[] +} + export interface Limits { + invoice?: InvoiceLimits + connection?: ConnectionLimits client?: ClientLimits event?: EventLimits message?: MessageLimits @@ -96,9 +120,62 @@ export interface Worker { count: number } -export interface ISettings { +export interface FeeScheduleWhitelists { + pubkeys?: Pubkey[] +} + +export interface FeeSchedule { + enabled: boolean + description?: string + amount: bigint + whitelists?: FeeScheduleWhitelists +} + +export interface FeeSchedules { + admission: FeeSchedule[] + publication: FeeSchedule[] +} + +export interface Payments { + enabled: boolean + processor: keyof PaymentsProcessors + feeSchedules: FeeSchedules +} + +export interface ZebedeePaymentsProcessor { + baseURL: string + callbackBaseURL: string + ipWhitelist: string[] +} + +export interface PaymentsProcessors { + zebedee?: ZebedeePaymentsProcessor +} + +export interface Local { + secret: Secret +} + +export interface Remote { + secret: Secret +} + +export interface Mirror { + address: string + filters?: SubscriptionFilter[] + secret?: Secret +} + +export interface Mirroring { + static?: Mirror[] +} + +export interface Settings { info: Info - network?: Network + payments?: Payments + paymentsProcessors?: PaymentsProcessors + network: Network workers?: Worker limits?: Limits + mirroring?: Mirroring } diff --git a/src/@types/user.ts b/src/@types/user.ts new file mode 100644 index 000000000..83a5237c7 --- /dev/null +++ b/src/@types/user.ts @@ -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 +} diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 63c8dd410..fcb5a39e9 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -46,6 +46,24 @@ export class RedisAdapter implements ICacheAdapter { // throw error } + public async hasKey(key: string): Promise { + await this.connection + debug('has %s key', key) + return Boolean(this.client.exists(key)) + } + + public async getKey(key: string): Promise { + await this.connection + debug('get %s key', key) + return this.client.get(key) + } + + public async setKey(key: string, value: string): Promise { + 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 { await this.connection debug('remove %d..%d range from sorted set %s', min, max, key) diff --git a/src/adapters/web-server-adapter.ts b/src/adapters/web-server-adapter.ts index befceed64..f22046e69 100644 --- a/src/adapters/web-server-adapter.ts +++ b/src/adapters/web-server-adapter.ts @@ -1,10 +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 { ISettings } from '../@types/settings' import { IWebServerAdapter } from '../@types/adapters' const debug = createLogger('web-server-adapter') @@ -12,12 +9,10 @@ const debug = createLogger('web-server-adapter') export class WebServerAdapter extends EventEmitter implements IWebServerAdapter { public constructor( protected readonly webServer: Server, - private readonly settings: () => ISettings, ) { - debug('web server starting') + debug('created') 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)) @@ -33,49 +28,31 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter debug('listening for incoming connections') } - private onRequest(request: IncomingMessage, response: ServerResponse) { - debug('request received: %O', request.headers) - if (request.method === 'GET' && request.headers['accept'] === 'application/nostr+json') { - const { - info: { name, description, pubkey, contact }, - } = this.settings() - - 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') { - response.setHeader('content-type', 'text/plain') - response.end('Please use a Nostr client to connect.') - } - } - private onError(error: Error) { - debug('error: %o', error) - - throw error + console.error('web-server-adapter: error:', error) } private onClientError(error: Error, socket: Duplex) { - debug('socket error: %o', error) + console.error('web-server-adapter: client socket error:', error) if (error['code'] === 'ECONNRESET' || !socket.writable) { return } socket.end('HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n') } + public close(callback?: () => void): void { + debug('closing') + this.webServer.close(() => { + this.webServer.removeAllListeners() + this.removeAllListeners() + if (typeof callback !== 'undefined') { + callback() + } + }) + debug('closed') + } + protected onClose() { debug('stopped listening to incoming connections') - this.webServer.removeAllListeners() } } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 371b94029..bc34a9c31 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -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,13 +11,16 @@ 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') @@ -25,7 +29,7 @@ const abortableMessageHandlers: WeakMap = new WeakMap() export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter { public clientId: string - private clientAddress: string + private clientAddress: SocketAddress private alive: boolean private subscriptions: Map @@ -35,27 +39,33 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private readonly webSocketServer: IWebSocketServerAdapter, private readonly createMessageHandler: Factory, private readonly slidingWindowRateLimiter: Factory, - private readonly settings: Factory, + private readonly settings: Factory, ) { super() this.alive = true this.subscriptions = new Map() this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex') - const remoteIpHeader = this.settings().network?.remote_ip_header ?? 'x-forwarded-for' - this.clientAddress = (this.request.headers[remoteIpHeader] ?? this.request.socket.remoteAddress) as string + + 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)) - .on('close', this.onClientClose.bind(this)) - .on('pong', this.onClientPong.bind(this)) .on('error', (error) => { if (error.name === 'RangeError' && error.message === 'Max payload size exceeded') { - debug('client %s from %s sent payload too large', this.clientId, this.clientAddress) + console.error(`web-socket-adapter: client ${this.clientId} (${this.getClientAddress()}) sent payload too large`) } else { - debug('error', error) + console.error(`web-socket-adapter: client error ${this.clientId} (${this.getClientAddress()}):`, error) } }) + .on('message', this.onClientMessage.bind(this)) + .on('close', this.onClientClose.bind(this)) + .on('pong', this.onClientPong.bind(this)) + .on('ping', this.onClientPing.bind(this)) this .on(WebSocketAdapterEvent.Heartbeat, this.onHeartbeat.bind(this)) @@ -65,7 +75,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 { @@ -73,7 +83,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } public getClientAddress(): string { - return this.clientAddress + return this.clientAddress.address } public onUnsubscribed(subscriptionId: string): void { @@ -108,12 +118,15 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } private sendMessage(message: OutgoingMessage): void { + if (this.client.readyState !== WebSocket.OPEN) { + return + } this.client.send(JSON.stringify(message)) } public onHeartbeat(): void { if (!this.alive) { - debug('client %s pong timed out', this.clientId) + console.error(`web-socket-adapter: pong timeout for client ${this.clientId} (${this.getClientAddress()})`) this.terminate() return } @@ -137,16 +150,20 @@ 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) + console.error('web-socket-adapter: unhandled message: no handler found:', message) return } @@ -162,17 +179,17 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } catch (error) { if (error instanceof Error) { if (error.name === 'AbortError') { - debug('message handler aborted') + console.error(`web-socket-adapter: abort from client ${this.clientId} (${this.getClientAddress()})`) } else if (error.name === 'SyntaxError' || error.name === 'ValidationError') { if (typeof (error as any).annotate === 'function') { - debug('invalid message: %o', (error as any).annotate()) + debug('invalid message client %s (%s): %o', this.clientId, this.getClientAddress(), (error as any).annotate()) } else { - debug('malformed message: %s', error.message) + console.error(`web-socket-adapter: malformed message from client ${this.clientId} (${this.getClientAddress()}):`, error.message) } this.sendMessage(createNoticeMessage(`invalid: ${error.message}`)) } } else { - console.error('unable to handle message', error) + console.error('web-socket-adapter: unable to handle message:', error) } } finally { if (abortable && messageHandler) { @@ -203,10 +220,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter rateLimiter.hit( `${client}:message:${period}`, 1, - { period: period, rate: rate }, + { period, rate }, ) - + let limited = false for (const { rate, period } of rateLimits) { const isRateLimited = await hit(period, rate) @@ -214,11 +231,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter if (isRateLimited) { debug('rate limited %s: %d messages / %d ms exceeded', client, rate, period) - return true + limited = true } } - return false + return limited } private onClientPong() { @@ -226,6 +243,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.alive = true } + private onClientPing(data: any) { + debugHeartbeat('client %s ping', this.clientId) + this.client.pong(data) + this.alive = true + } + private onClientClose() { this.alive = false this.subscriptions.clear() @@ -233,13 +256,15 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const handlers = abortableMessageHandlers.get(this.client) if (Array.isArray(handlers) && handlers.length) { for (const handler of handlers) { - handler.abort() + try { + handler.abort() + } catch (error) { + console.error('Unable to abort message handler', error) + } } } this.removeAllListeners() this.client.removeAllListeners() - - debug('client %s closed', this.clientId) } } diff --git a/src/adapters/web-socket-server-adapter.ts b/src/adapters/web-socket-server-adapter.ts index 977dc02d6..e07c66d46 100644 --- a/src/adapters/web-socket-server-adapter.ts +++ b/src/adapters/web-socket-server-adapter.ts @@ -1,18 +1,20 @@ 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 { 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') -const WSS_CLIENT_HEALTH_PROBE_INTERVAL = 30000 +const WSS_CLIENT_HEALTH_PROBE_INTERVAL = 60000 export class WebSocketServerAdapter extends WebServerAdapter implements IWebSocketServerAdapter { private webSocketsAdapters: WeakMap @@ -26,9 +28,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock IWebSocketAdapter, [WebSocket, IncomingMessage, IWebSocketServerAdapter] >, - settings: () => ISettings, + private readonly settings: () => Settings, ) { - super(webServer, settings) + debug('created') + super(webServer) this.webSocketsAdapters = new WeakMap() @@ -36,20 +39,31 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock .on(WebSocketServerAdapterEvent.Broadcast, this.onBroadcast.bind(this)) this.webSocketServer - .on(WebSocketServerAdapterEvent.Close, this.onClose.bind(this)) .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) } - public close(callback: () => void): void { - this.onClose() - this.webSocketServer.close(() => { - this.webServer.close(callback) + public close(callback?: () => void): void { + super.close(() => { + debug('closing') + clearInterval(this.heartbeatInterval) + this.webSocketServer.clients.forEach((webSocket: WebSocket) => { + debug('terminating client %s', this.webSocketsAdapters.get(webSocket).getClientId()) + webSocket.terminate() + }) + debug('closing web socket server') + this.webSocketServer.close(() => { + this.webSocketServer.removeAllListeners() + if (typeof callback !== 'undefined') { + callback() + } + debug('closed') + }) }) + this.removeAllListeners() } private onBroadcast(event: Event) { @@ -58,6 +72,9 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock return } const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter + if (!webSocketAdapter) { + return + } webSocketAdapter.emit(WebSocketAdapterEvent.Event, event) }) } @@ -66,28 +83,27 @@ 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])) } private onHeartbeat() { this.webSocketServer.clients.forEach((webSocket) => { const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter - webSocketAdapter.emit(WebSocketAdapterEvent.Heartbeat) - }) - } - - protected onClose() { - debug('closing') - clearInterval(this.heartbeatInterval) - this.webSocketServer.clients.forEach((webSocket: WebSocket) => { - debug('terminating client') - webSocket.terminate() + if (webSocketAdapter) { + webSocketAdapter.emit(WebSocketAdapterEvent.Heartbeat) + } }) - this.removeAllListeners() - this.webSocketServer.removeAllListeners() - super.onClose() - debug('closed') } } diff --git a/src/app/app.ts b/src/app/app.ts index a1afe7558..5715c79e4 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,23 +1,31 @@ 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 workers: WeakMap> + 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') + this.workers = new WeakMap() + this.cluster .on('message', this.onClusterMessage.bind(this)) .on('exit', this.onClusterExit.bind(this)) @@ -29,6 +37,8 @@ export class App implements IRunnable { } public run(): void { + this.watchers = SettingsStatic.watchSettings() + const settings = this.settings() console.log(` ███▄ █ ▒█████ ██████ ▄▄▄█████▓ ██▀███ ▓█████ ▄▄▄ ███▄ ▄███▓ ██ ▀█ █ ▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒▓██ ▒ ██▒▓█ ▀▒████▄ ▓██▒▀█▀ ██▒ @@ -44,36 +54,60 @@ 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 + + const createWorker = (env: Record) => { + const worker = this.cluster.fork(env) + this.workers.set(worker, env) + } for (let i = 0; i < workerCount; i++) { debug('starting worker') - this.cluster.fork() + createWorker({ + WORKER_TYPE: 'worker', + }) + } + + createWorker({ + WORKER_TYPE: 'maintenance', + }) + + const mirrors = settings?.mirroring?.static + + if (Array.isArray(mirrors) && mirrors.length) { + for (let i = 0; i < mirrors.length; i++) { + createWorker({ + WORKER_TYPE: 'static-mirroring', + MIRROR_INDEX: i.toString(), + }) + } } 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 } @@ -85,16 +119,39 @@ export class App implements IRunnable { private onClusterExit(deadWorker: Worker, code: number, signal: string) { debug('worker %s died', deadWorker.process.pid) + if (code === 0 || signal === 'SIGINT') { return } - debug('starting worker') - const newWorker = this.cluster.fork() - debug('started worker %s', newWorker.process.pid) + setTimeout(() => { + debug('starting worker') + const workerEnv = this.workers.get(deadWorker) + if (!workerEnv) { + throw new Error('Mistakes were made') + } + const newWorker = this.cluster.fork(workerEnv) + this.workers.set(newWorker, workerEnv) + + debug('started worker %s', newWorker.process.pid) + }, 10000) } 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() + } } } \ No newline at end of file diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts new file mode 100644 index 000000000..bc7ddc504 --- /dev/null +++ b/src/app/maintenance-worker.ts @@ -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 { + 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() + } + } +} diff --git a/src/app/static-mirroring-worker.ts b/src/app/static-mirroring-worker.ts new file mode 100644 index 000000000..9c2500ecf --- /dev/null +++ b/src/app/static-mirroring-worker.ts @@ -0,0 +1,152 @@ +import { anyPass, map, path } from 'ramda' +import { RawData, WebSocket } from 'ws' +import cluster from 'cluster' +import { randomUUID } from 'crypto' + +import { createRelayedEventMessage, createSubscriptionMessage } from '../utils/messages' +import { isEventIdValid, isEventMatchingFilter, isEventSignatureValid } from '../utils/event' +import { Mirror, Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' +import { IRunnable } from '../@types/base' +import { OutgoingEventMessage } from '../@types/messages' +import { RelayedEvent } from '../@types/event' +import { WebSocketServerAdapterEvent } from '../constants/adapter' + +const debug = createLogger('static-mirror-worker') + +export class StaticMirroringWorker implements IRunnable { + private client: WebSocket | undefined + private config: Mirror + + public constructor( + private readonly process: NodeJS.Process, + private readonly settings: () => Settings, + ) { + this.process + .on('message', this.onMessage.bind(this)) + .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 { + const currentSettings = this.settings() + + console.log('mirroring', currentSettings.mirroring) + + this.config = path(['mirroring', 'static', process.env.MIRROR_INDEX], currentSettings) as Mirror + + let since = Math.floor(Date.now() / 1000) - 60*10 + + const createMirror = (config: Mirror) => { + const subscriptionId = `mirror-${randomUUID()}` + + debug('connecting to %s', config.address) + + return new WebSocket(config.address, { timeout: 5000 }) + .on('open', function () { + debug('connected to %s', config.address) + + if (Array.isArray(config.filters) && config.filters?.length) { + const filters = config.filters.map((filter) => ({ ...filter, since })) + + debug('subscribing with %s: %o', subscriptionId, filters) + + this.send(JSON.stringify(createSubscriptionMessage(subscriptionId, filters))) + } + }) + .on('message', async function (raw: RawData) { + try { + const message = JSON.parse(raw.toString('utf8')) as OutgoingEventMessage + + if (!Array.isArray(message)) { + return + } + + if (message[0] !== 'EVENT' || message[1] !== subscriptionId) { + debug('%s >> local: %o', config.address, message) + return + } + + const event = message[2] + + if (!anyPass(map(isEventMatchingFilter, config.filters))(event)) { + return + } + + if (!await isEventIdValid(event) || !await isEventSignatureValid(event)) { + return + } + + since = Math.floor(Date.now() / 1000) - 30 + + if (cluster.isWorker && typeof process.send === 'function') { + debug('%s >> local: %s', config.address, event.id) + process.send({ + eventName: WebSocketServerAdapterEvent.Broadcast, + event, + source: config.address, + }) + } + } catch (error) { + debug('unable to process message: %o', error) + } + }) + .on('close', (code, reason) => { + debug(`disconnected (${code}): ${reason.toString()}`) + + setTimeout(() => { + this.client.removeAllListeners() + this.client = createMirror(config) + }, 5000) + }) + .on('error', function (error) { + debug('connection error: %o', error) + }) + } + + this.client = createMirror(this.config) + } + + private onMessage(message: { eventName: string, event: unknown, source: string }): void { + if ( + message.eventName !== WebSocketServerAdapterEvent.Broadcast + || message.source === this.config.address + || !this.client + || this.client.readyState !== WebSocket.OPEN + ) { + return + } + + const event = message.event as RelayedEvent + + const eventToRelay = createRelayedEventMessage(event, this.config.secret) + const outboundMessage = JSON.stringify(eventToRelay) + debug('%s >> %s: %s', message.source ?? 'local', this.config.address, outboundMessage) + this.client.send(outboundMessage) + } + + 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') + if (this.client) { + this.client.terminate() + } + if (typeof callback === 'function') { + callback() + } + } +} diff --git a/src/app/worker.ts b/src/app/worker.ts index 721bdcd5a..b5cce811f 100644 --- a/src/app/worker.ts +++ b/src/app/worker.ts @@ -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,12 @@ 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() + } + } + this.adapter.close(callback) + debug('closed') } } diff --git a/src/constants/adapter.ts b/src/constants/adapter.ts index 79bdbca73..d463d781d 100644 --- a/src/constants/adapter.ts +++ b/src/constants/adapter.ts @@ -9,6 +9,5 @@ export enum WebSocketAdapterEvent { export enum WebSocketServerAdapterEvent { Broadcast = 'broadcast', - Close = 'close', Connection = 'connection' } diff --git a/src/constants/base.ts b/src/constants/base.ts index d36e84770..7af91f082 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -14,6 +14,9 @@ export enum EventKinds { CHANNEL_MUTE_USER = 44, CHANNEL_RESERVED_FIRST = 45, CHANNEL_RESERVED_LAST = 49, + // Relay-only + RELAY_INVITE = 50, + INVOICE_UPDATE = 402, // Replaceable events REPLACEABLE_FIRST = 10000, REPLACEABLE_LAST = 19999, @@ -35,5 +38,10 @@ export enum EventTags { Deduplication = 'd', } +export enum PaymentsProcessors { + ZEBEDEE = 'zebedee', +} + export const EventDelegatorMetadataKey = Symbol('Delegator') export const EventDeduplicationMetadataKey = Symbol('Deduplication') +export const ContextMetadataKey = Symbol('Context') diff --git a/src/constants/payments.ts b/src/constants/payments.ts new file mode 100644 index 000000000..ac9b638d1 --- /dev/null +++ b/src/constants/payments.ts @@ -0,0 +1,4 @@ +export enum FeeSchedules { + ADMISSION = 'admission', + PUBLICATION = 'publication' +} diff --git a/src/controllers/callbacks/zebedee-callback-controller.ts b/src/controllers/callbacks/zebedee-callback-controller.ts new file mode 100644 index 000000000..e1fa6e86a --- /dev/null +++ b/src/controllers/callbacks/zebedee-callback-controller.ts @@ -0,0 +1,63 @@ +import { Request, Response } from 'express' + +import { createLogger } from '../../factories/logger-factory' +import { fromZebedeeInvoice } from '../../utils/transform' +import { IController } from '../../@types/controllers' +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 paymentsService: IPaymentsService, + ) {} + + // TODO: Validate + public async handleRequest( + request: Request, + response: Response, + ) { + debug('request headers: %o', request.headers) + debug('request body: %o', request.body) + + const invoice = fromZebedeeInvoice(request.body) + + debug('invoice', invoice) + + try { + await this.paymentsService.updateInvoice(invoice) + } catch (error) { + console.error(`Unable to persist invoice ${invoice.id}`, error) + + throw error + } + + if ( + invoice.status !== InvoiceStatus.COMPLETED + && !invoice.confirmedAt + ) { + response + .status(200) + .send() + + return + } + + invoice.amountPaid = invoice.amountRequested + + try { + await this.paymentsService.confirmInvoice(invoice) + await this.paymentsService.sendInvoiceUpdateNotification(invoice) + } catch (error) { + console.error(`Unable to confirm invoice ${invoice.id}`, error) + + throw error + } + + response + .status(200) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('OK') + } +} diff --git a/src/controllers/invoices/post-invoice-controller.ts b/src/controllers/invoices/post-invoice-controller.ts new file mode 100644 index 000000000..0065ef4a6 --- /dev/null +++ b/src/controllers/invoices/post-invoice-controller.ts @@ -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 { + 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 + } +} \ No newline at end of file diff --git a/src/database/client.ts b/src/database/client.ts index e03b594c4..c91fe424f 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -3,7 +3,27 @@ import 'pg-query-stream' import knex, { Knex } from 'knex' import { createLogger } from '../factories/logger-factory' +((knex) => { + const lastUpdate = {} + knex.Client.prototype.releaseConnection = function (connection) { + const released = this.pool.release(connection) + + if (released) { + const now = new Date().getTime() + const { tag } = this.config + lastUpdate[tag] = lastUpdate[tag] ?? now + if (now - lastUpdate[tag] >= 60000) { + lastUpdate[tag] = now + console.log(`${tag} connection pool: ${this.pool.numUsed()} used / ${this.pool.numFree()} free / ${this.pool.numPendingAcquires()} pending`) + } + } + + return Promise.resolve() + } +})(knex) + const getMasterConfig = (): Knex.Config => ({ + tag: 'master', client: 'pg', connection: { host: process.env.DB_HOST, @@ -24,9 +44,10 @@ const getMasterConfig = (): Knex.Config => ({ acquireConnectionTimeout: process.env.DB_ACQUIRE_CONNECTION_TIMEOUT ? Number(process.env.DB_ACQUIRE_CONNECTION_TIMEOUT) : 60000, -}) +} as any) const getReadReplicaConfig = (): Knex.Config => ({ + tag: 'read-replica', client: 'pg', connection: { host: process.env.RR_DB_HOST, @@ -44,10 +65,7 @@ const getReadReplicaConfig = (): Knex.Config => ({ ? Number(process.env.RR_DB_ACQUIRE_CONNECTION_TIMEOUT) : 60000, }, - acquireConnectionTimeout: process.env.RR_DB_ACQUIRE_CONNECTION_TIMEOUT - ? Number(process.env.RR_DB_ACQUIRE_CONNECTION_TIMEOUT) - : 60000, -}) +} as any) let writeClient: Knex diff --git a/src/database/transaction.ts b/src/database/transaction.ts new file mode 100644 index 000000000..7301d4dd1 --- /dev/null +++ b/src/database/transaction.ts @@ -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 + + public constructor( + private readonly dbClient: DatabaseClient, + ) {} + + public async begin(): Promise { + 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 { + if (!this.trx) { + throw new Error('Unable to get transaction: transaction not started.') + } + return this.trx.commit() + } + + public async rollback(): Promise { + if (!this.trx) { + throw new Error('Unable to get transaction: transaction not started.') + } + return this.trx.rollback() + } +} \ No newline at end of file diff --git a/src/factories/logger-factory.ts b/src/factories/logger-factory.ts index a27d75eed..312960432 100644 --- a/src/factories/logger-factory.ts +++ b/src/factories/logger-factory.ts @@ -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}:*`) diff --git a/src/factories/maintenance-worker-factory.ts b/src/factories/maintenance-worker-factory.ts new file mode 100644 index 000000000..2436c9984 --- /dev/null +++ b/src/factories/maintenance-worker-factory.ts @@ -0,0 +1,28 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' +import { createPaymentsProcessor } from './payments-processor-factory' +import { createSettings } from './settings-factory' +import { EventRepository } from '../repositories/event-repository' +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 = getMasterDbClient() + const rrDbClient = getReadReplicaDbClient() + const paymentsProcessor = createPaymentsProcessor() + const userRepository = new UserRepository(dbClient) + const invoiceRepository = new InvoiceRepository(dbClient) + const eventRepository = new EventRepository(dbClient, rrDbClient) + + const paymentsService = new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + createSettings, + ) + + return new MaintenanceWorker(process, paymentsService, createSettings) +} diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 9ebda77c5..a3123ce2f 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -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, ) diff --git a/src/factories/payments-processor-factory.ts b/src/factories/payments-processor-factory.ts new file mode 100644 index 000000000..367d879e4 --- /dev/null +++ b/src/factories/payments-processor-factory.ts @@ -0,0 +1,64 @@ +import axios, { CreateAxiosDefaults } from 'axios' +import { path } from 'ramda' + +import { createLogger } from './logger-factory' +import { createSettings } from './settings-factory' +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 debug = createLogger('create-zebedee-payments-processor') + +const getConfig = (settings: Settings): CreateAxiosDefaults => { + 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: 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 = (): IPaymentsProcessor => { + const settings = createSettings() + if (!settings.payments?.enabled) { + return new NullPaymentsProcessor() + } + + + switch (settings.payments?.processor) { + case 'zebedee': + return createZebedeePaymentsProcessor(settings) + default: + return new NullPaymentsProcessor() + } +} diff --git a/src/factories/payments-service-factory.ts b/src/factories/payments-service-factory.ts new file mode 100644 index 000000000..abd97ddd3 --- /dev/null +++ b/src/factories/payments-service-factory.ts @@ -0,0 +1,25 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' +import { createPaymentsProcessor } from './payments-processor-factory' +import { createSettings } from './settings-factory' +import { EventRepository } from '../repositories/event-repository' +import { InvoiceRepository } from '../repositories/invoice-repository' +import { PaymentsService } from '../services/payments-service' +import { UserRepository } from '../repositories/user-repository' + +export const createPaymentsService = () => { + const dbClient = getMasterDbClient() + const rrDbClient = getReadReplicaDbClient() + const invoiceRepository = new InvoiceRepository(dbClient) + const userRepository = new UserRepository(dbClient) + const paymentsProcessor = createPaymentsProcessor() + const eventRepository = new EventRepository(dbClient, rrDbClient) + + return new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + createSettings + ) +} diff --git a/src/factories/post-invoice-controller-factory.ts b/src/factories/post-invoice-controller-factory.ts new file mode 100644 index 000000000..e3f57b277 --- /dev/null +++ b/src/factories/post-invoice-controller-factory.ts @@ -0,0 +1,34 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' +import { createPaymentsProcessor } from './payments-processor-factory' +import { createSettings } from './settings-factory' +import { EventRepository } from '../repositories/event-repository' +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 = getMasterDbClient() + const rrDbClient = getReadReplicaDbClient() + const eventRepository = new EventRepository(dbClient, rrDbClient) + 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, + ) +} diff --git a/src/factories/settings-factory.ts b/src/factories/settings-factory.ts index 1265d0108..21dc1c202 100644 --- a/src/factories/settings-factory.ts +++ b/src/factories/settings-factory.ts @@ -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() diff --git a/src/factories/static-mirroring.worker-factory.ts b/src/factories/static-mirroring.worker-factory.ts new file mode 100644 index 000000000..999081453 --- /dev/null +++ b/src/factories/static-mirroring.worker-factory.ts @@ -0,0 +1,6 @@ +import { createSettings } from './settings-factory' +import { StaticMirroringWorker } from '../app/static-mirroring-worker' + +export const staticMirroringWorkerFactory = () => { + return new StaticMirroringWorker(process, createSettings) +} diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index 481321f30..8d92fa6f6 100644 --- a/src/factories/websocket-adapter-factory.ts +++ b/src/factories/websocket-adapter-factory.ts @@ -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, ) diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 8b7d54f8a..b66b6eced 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -1,3 +1,6 @@ +import { is, path, pathSatisfies } from 'ramda' +import express from 'express' +import helmet from 'helmet' import http from 'http' import process from 'process' import { WebSocketServer } from 'ws' @@ -6,6 +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 { UserRepository } from '../repositories/user-repository' import { webSocketAdapterFactory } from './websocket-adapter-factory' import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' @@ -13,17 +19,52 @@ 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(rateLimiterMiddleware) + .use(helmet.contentSecurityPolicy({ + directives: { + /** + * TODO: Remove 'unsafe-inline' + */ + 'img-src': ["'self'", 'https://cdn.zebedee.io/an/nostr/'], + 'connect-src': [settings.info.relay_url as string], + 'default-src': ['"self"'], + 'script-src-attr': ["'unsafe-inline'"], + 'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/', 'https://cdnjs.cloudflare.com/ajax/libs/'], + 'style-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'], + 'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'], + }, + })) + .use('/favicon.ico', express.static('./resources/favicon.ico')) + .use('/css', express.static('./resources/css')) + .use(router) // deepcode ignore HttpToHttps: we use proxies - const server = http.createServer() + const server = http.createServer(app) + + let maxPayloadSize: number | undefined + 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 = path(['network', 'max_payload_size'], settings) + } else { + maxPayloadSize = path(['network', 'maxPayloadSize'], settings) + } + const webSocketServer = new WebSocketServer({ server, - maxPayload: createSettings().network?.max_payload_size ?? 131072, // 128 kB + maxPayload: maxPayloadSize ?? 131072, // 128 kB }) const adapter = new WebSocketServerAdapter( server, webSocketServer, - webSocketAdapterFactory(eventRepository), + webSocketAdapterFactory(eventRepository, userRepository), createSettings, ) diff --git a/src/factories/zebedee-callback-controller-factory.ts b/src/factories/zebedee-callback-controller-factory.ts new file mode 100644 index 000000000..28fd957c4 --- /dev/null +++ b/src/factories/zebedee-callback-controller-factory.ts @@ -0,0 +1,30 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' +import { createPaymentsProcessor } from './payments-processor-factory' +import { createSettings } from './settings-factory' +import { EventRepository } from '../repositories/event-repository' +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 = getMasterDbClient() + const rrDbClient = getReadReplicaDbClient() + const eventRepository = new EventRepository(dbClient, rrDbClient) + const invoiceRepotistory = new InvoiceRepository(dbClient) + const userRepository = new UserRepository(dbClient) + const paymentsProcessor = createPaymentsProcessor() + const paymentsService = new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepotistory, + eventRepository, + createSettings, + ) + + return new ZebedeeCallbackController( + paymentsService, + ) +} diff --git a/src/handlers/delegated-event-message-handler.ts b/src/handlers/delegated-event-message-handler.ts index 51dc7ff78..13ede1f2c 100644 --- a/src/handlers/delegated-event-message-handler.ts +++ b/src/handlers/delegated-event-message-handler.ts @@ -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, diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 61cf0f2f1..3201fa1de 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -1,13 +1,14 @@ -import { EventKindsRange, EventRateLimit, ISettings } from '../@types/settings' -import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventSignatureValid } from '../utils/event' +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 { EventKinds } from '../constants/base' 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' @@ -17,13 +18,16 @@ export class EventMessageHandler implements IMessageHandler { public constructor( protected readonly webSocket: IWebSocketAdapter, protected readonly strategyFactory: Factory>, [Event, IWebSocketAdapter]>, - private readonly settings: () => ISettings, + protected readonly userRepository: IUserRepository, + private readonly settings: () => Settings, private readonly slidingWindowRateLimiter: Factory, ) {} public async handleMessage(message: IncomingEventMessage): Promise { const [, event] = message + event[ContextMetadataKey] = message[ContextMetadataKey] + let reason = await this.isEventValid(event) if (reason) { debug('event %s rejected: %s', event.id, reason) @@ -44,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') { @@ -61,27 +72,63 @@ export class EventMessageHandler implements IMessageHandler { protected canAcceptEvent(event: Event): string | undefined { const now = Math.floor(Date.now()/1000) - const limits = this.settings().limits.event - if (limits.content.maxLength > 0 && event.content.length > limits.content.maxLength) { + + const limits = this.settings().limits?.event ?? {} + + if (Array.isArray(limits.content)) { + for (const limit of limits.content) { + if ( + typeof limit.maxLength !== 'undefined' + && limit.maxLength > 0 + && event.content.length > limit.maxLength + && ( + !Array.isArray(limit.kinds) + || limit.kinds.some(isEventKindOrRangeMatch(event)) + ) + ) { + return `rejected: content is longer than ${limit.maxLength} bytes` + } + } + } else if ( + typeof limits.content?.maxLength !== 'undefined' + && limits.content?.maxLength > 0 + && event.content.length > limits.content.maxLength + && ( + !Array.isArray(limits.content.kinds) + || limits.content.kinds.some(isEventKindOrRangeMatch(event)) + ) + ) { return `rejected: content is longer than ${limits.content.maxLength} bytes` } - if (limits.createdAt.maxPositiveDelta > 0 && event.created_at > now + limits.createdAt.maxPositiveDelta) { + if ( + typeof limits.createdAt?.maxPositiveDelta !== 'undefined' + && limits.createdAt.maxPositiveDelta > 0 + && event.created_at > now + limits.createdAt.maxPositiveDelta) { return `rejected: created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future` } - if (limits.createdAt.maxNegativeDelta > 0 && event.created_at < now - limits.createdAt.maxNegativeDelta) { + if ( + typeof limits.createdAt?.maxNegativeDelta !== 'undefined' + && limits.createdAt.maxNegativeDelta > 0 + && event.created_at < now - limits.createdAt.maxNegativeDelta) { return `rejected: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past` } - if (limits.eventId.minLeadingZeroBits > 0) { + if ( + typeof limits.eventId?.minLeadingZeroBits !== 'undefined' + && limits.eventId.minLeadingZeroBits > 0 + ) { const pow = getEventProofOfWork(event.id) if (pow < limits.eventId.minLeadingZeroBits) { return `pow: difficulty ${pow}<${limits.eventId.minLeadingZeroBits}` } } - if (limits.pubkey.minLeadingZeroBits > 0) { + if ( + typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' + && limits.pubkey.minLeadingZeroBits > 0 + ) { const pow = getPubkeyProofOfWork(event.pubkey) if (pow < limits.pubkey.minLeadingZeroBits) { return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}` @@ -89,29 +136,32 @@ export class EventMessageHandler implements IMessageHandler { } if ( - limits.pubkey.whitelist.length > 0 + typeof limits.pubkey?.whitelist !== 'undefined' + && limits.pubkey.whitelist.length > 0 && !limits.pubkey.whitelist.some((prefix) => event.pubkey.startsWith(prefix)) ) { return 'blocked: pubkey not allowed' } if ( - limits.pubkey.blacklist.length > 0 + typeof limits.pubkey?.blacklist !== 'undefined' + && limits.pubkey.blacklist.length > 0 && limits.pubkey.blacklist.some((prefix) => event.pubkey.startsWith(prefix)) ) { return 'blocked: pubkey not allowed' } - const isEventKindMatch = (item: EventKinds | EventKindsRange) => - typeof item === 'number' - ? item === event.kind - : event.kind >= item[0] && event.kind <= item[1] - - if (limits.kind.whitelist.length > 0 && !limits.kind.whitelist.some(isEventKindMatch)) { + if ( + typeof limits.kind?.whitelist !== 'undefined' + && limits.kind.whitelist.length > 0 + && !limits.kind.whitelist.some(isEventKindOrRangeMatch(event))) { return `blocked: event kind ${event.kind} not allowed` } - if (limits.kind.blacklist.length > 0 && limits.kind.blacklist.some(isEventKindMatch)) { + if ( + typeof limits.kind?.blacklist !== 'undefined' + && limits.kind.blacklist.length > 0 + && limits.kind.blacklist.some(isEventKindOrRangeMatch(event))) { return `blocked: event kind ${event.kind} not allowed` } } @@ -132,13 +182,16 @@ export class EventMessageHandler implements IMessageHandler { } if ( - Array.isArray(whitelists?.pubkeys) + typeof whitelists?.pubkeys !== 'undefined' + && Array.isArray(whitelists?.pubkeys) && whitelists.pubkeys.includes(event.pubkey) ) { return false } - if (Array.isArray(whitelists?.ipAddresses) + if ( + typeof whitelists?.ipAddresses !== 'undefined' + && Array.isArray(whitelists?.ipAddresses) && whitelists.ipAddresses.includes(this.webSocket.getClientAddress()) ) { return false @@ -162,10 +215,50 @@ export class EventMessageHandler implements IMessageHandler { ) } - const hits = await Promise.all(rateLimits.map(hit)) + let limited = false + for (const { rate, period, kinds } of rateLimits) { + // skip if event kind does not apply + if (Array.isArray(kinds) && !kinds.some(isEventKindOrRangeMatch(event))) { + continue + } + + const isRateLimited = await hit({ period, rate, kinds }) + + if (isRateLimited) { + debug('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period) - debug('rate limit check %s: %o', event.pubkey, hits) + limited = true + } + } - return hits.some((active) => active) + return limited + } + + protected async isUserAdmitted(event: Event): Promise { + 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' + } } } diff --git a/src/handlers/request-handlers/get-invoice-request-handler.ts b/src/handlers/request-handlers/get-invoice-request-handler.ts new file mode 100644 index 000000000..52941a8ed --- /dev/null +++ b/src/handlers/request-handlers/get-invoice-request-handler.ts @@ -0,0 +1,29 @@ +import { NextFunction, Request, Response } from 'express' +import { path, pathEq } from 'ramda' +import { readFileSync } from 'fs' + +import { createSettings } from '../../factories/settings-factory' +import { FeeSchedule } from '../../@types/settings' + +let pageCache: string + +export const getInvoiceRequestHandler = (_req: Request, res: Response, next: NextFunction) => { + const settings = createSettings() + + if (pathEq(['payments', 'enabled'], true, settings) + && pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings)) { + if (!pageCache) { + const name = path(['info', 'name'])(settings) + const feeSchedule = path(['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() + } + + next() +} diff --git a/src/handlers/request-handlers/get-terms-request-handler.ts b/src/handlers/request-handlers/get-terms-request-handler.ts new file mode 100644 index 000000000..c7c59cd30 --- /dev/null +++ b/src/handlers/request-handlers/get-terms-request-handler.ts @@ -0,0 +1,17 @@ +import { NextFunction, Request, Response } from 'express' +import { readFileSync } from 'fs' + +import { createSettings as settings } from '../../factories/settings-factory' + +let pageCache: string + +export const getTermsRequestHandler = (_req: Request, res: Response, next: NextFunction) => { + const { info: { name } } = settings() + + if (!pageCache) { + pageCache = readFileSync('./resources/terms.html', 'utf8').replaceAll('{{name}}', name) + } + + res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache) + next() +} diff --git a/src/handlers/request-handlers/post-invoice-request-handler.ts b/src/handlers/request-handlers/post-invoice-request-handler.ts new file mode 100644 index 000000000..6d857bae5 --- /dev/null +++ b/src/handlers/request-handlers/post-invoice-request-handler.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express' +import { createPostInvoiceController } from '../../factories/post-invoice-controller-factory' + +export const postInvoiceRequestHandler = async ( + req: Request, + res: Response, +) => { + const controller = createPostInvoiceController() + + try { + await controller.handleRequest(req, res) + } catch (error) { + res + .status(500) + .setHeader('content-type', 'text-plain') + .send('Error handling request') + } +} diff --git a/src/handlers/request-handlers/post-zebedee-callback-request-handler.ts b/src/handlers/request-handlers/post-zebedee-callback-request-handler.ts new file mode 100644 index 000000000..1bff98b21 --- /dev/null +++ b/src/handlers/request-handlers/post-zebedee-callback-request-handler.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express' +import { createZebedeeCallbackController } from '../../factories/zebedee-callback-controller-factory' + +export const postZebedeeCallbackRequestHandler = async ( + req: Request, + res: Response, +) => { + const controller = createZebedeeCallbackController() + + try { + await controller.handleRequest(req, res) + } catch (error) { + res + .status(500) + .setHeader('content-type', 'text-plain') + .send('Error handling request') + } +} diff --git a/src/handlers/request-handlers/rate-limiter-middleware.ts b/src/handlers/request-handlers/rate-limiter-middleware.ts new file mode 100644 index 000000000..86c2e8372 --- /dev/null +++ b/src/handlers/request-handlers/rate-limiter-middleware.ts @@ -0,0 +1,61 @@ +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() +} + +export async function isRateLimited(remoteAddress: string, settings: Settings): Promise { + const { + rateLimits, + ipWhitelist = [], + } = settings.limits?.connection ?? {} + + if (typeof rateLimits === 'undefined') { + return false + } + + 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 +} diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts new file mode 100644 index 000000000..c0d41de0d --- /dev/null +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -0,0 +1,42 @@ +import { NextFunction, Request, Response } from 'express' +import { path } from 'ramda' + +import { createSettings } from '../../factories/settings-factory' +import packageJson from '../../../package.json' + +export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { + const settings = createSettings() + + if (request.header('accept') === 'application/nostr+json') { + const { + info: { name, description, pubkey, contact }, + } = settings + + const relayInformationDocument = { + name, + description, + pubkey, + contact, + supported_nips: packageJson.supportedNips, + software: packageJson.repository.url, + version: packageJson.version, + } + + response + .setHeader('conten-type', 'application/nostr+json') + .setHeader('access-control-allow-origin', '*') + .status(200) + .send(relayInformationDocument) + + return + } + + const admissionFeeEnabled = path(['payments','feeSchedules','admission', '0', 'enabled'])(settings) + + if (admissionFeeEnabled) { + response.redirect(301, '/invoices') + } else { + response.status(200).setHeader('content-type', 'text/plain; charset=utf8').send('Please use a Nostr client to connect.') + } + next() +} diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index cbaef4bb4..abb47ec3f 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -1,5 +1,5 @@ import { anyPass, equals, isNil, map, propSatisfies, uniqWith } from 'ramda' -import { addAbortSignal } from 'stream' +// import { addAbortSignal } from 'stream' import { pipeline } from 'stream/promises' import { createEndOfStoredEventsNoticeMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' @@ -10,26 +10,26 @@ 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' const debug = createLogger('subscribe-message-handler') export class SubscribeMessageHandler implements IMessageHandler, IAbortable { - private readonly abortController: AbortController + //private readonly abortController: AbortController public constructor( private readonly webSocket: IWebSocketAdapter, private readonly eventRepository: IEventRepository, - private readonly settings: () => ISettings, + private readonly settings: () => Settings, ) { - this.abortController = new AbortController() + //this.abortController = new AbortController() } public abort(): void { - this.abortController.abort() + //this.abortController.abort() } public async handleMessage(message: SubscribeMessage): Promise { @@ -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, + findEvents, streamFilter(propSatisfies(isNil, 'deleted_at')), streamMap(toNostrEvent), streamFilter(isSubscribedToEvent), @@ -71,8 +71,8 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { ) } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - debug('subscription aborted: %o', error) - findEvents.destroy() + debug('subscription %s aborted: %o', subscriptionId, error) + findEvents.destroy() } else { debug('error streaming events: %o', error) } diff --git a/src/index.ts b/src/index.ts index a60184a79..7d8ef98b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,15 +3,27 @@ import dotenv from 'dotenv' dotenv.config() import { appFactory } from './factories/app-factory' +import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' +import { staticMirroringWorkerFactory } from './factories/static-mirroring.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() + case 'static-mirroring': + return staticMirroringWorkerFactory() + default: + throw new Error(`Unknown worker: ${process.env.WORKER_TYPE}`) + } + } } if (require.main === module) { - getRunner(cluster.isPrimary).run() + getRunner().run() } diff --git a/src/payments-processors/null-payments-processor.ts b/src/payments-processors/null-payments-processor.ts new file mode 100644 index 000000000..69f9f6dff --- /dev/null +++ b/src/payments-processors/null-payments-processor.ts @@ -0,0 +1,37 @@ +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 { + 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 { + return { + description: '', + status: InvoiceStatus.PENDING, + unit: InvoiceUnit.MSATS, + amountRequested: 0n, + id: '', + expiresAt: new Date(), + bolt11: '', + pubkey: '', + rawResponse: '', + confirmedAt: null, + createdAt: new Date(), + } + } +} diff --git a/src/payments-processors/payments-procesor.ts b/src/payments-processors/payments-procesor.ts new file mode 100644 index 000000000..ad64f4892 --- /dev/null +++ b/src/payments-processors/payments-procesor.ts @@ -0,0 +1,16 @@ +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 { + return this.processor.getInvoice(invoiceId) + } + + public async createInvoice(request: CreateInvoiceRequest): Promise { + return this.processor.createInvoice(request) + } +} diff --git a/src/payments-processors/zebedee-payments-processor.ts b/src/payments-processors/zebedee-payments-processor.ts new file mode 100644 index 000000000..1b8339db6 --- /dev/null +++ b/src/payments-processors/zebedee-payments-processor.ts @@ -0,0 +1,65 @@ +import { AxiosInstance } from 'axios' +import { Factory } from '../@types/base' + +import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients' +import { createLogger } from '../factories/logger-factory' +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 + ) {} + + public async getInvoice(invoiceId: string): Promise { + 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 { + debug('create invoice: %o', request) + const { + amount: amountMsats, + description, + requestId, + } = request + + const body = { + amount: amountMsats.toString(), + description, + internalId: requestId, + callbackUrl: this.settings().paymentsProcessors?.zebedee?.callbackBaseURL, + } + + try { + debug('request body: %o', body) + const response = await this.httpClient.post('/v0/charges', body, { + maxRedirects: 1, + }) + + const result = fromZebedeeInvoice(response.data.data) + + debug('result: %o', result) + + return result + } catch (error) { + console.error('Unable to request invoice. Reason:', error.message) + + throw error + } + } +} diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 1616a3726..b9fb6a957 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -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( 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,13 +133,13 @@ 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( isEmpty, () => andWhereRaw('1 = 0', bd), - forEach((criterion: string[]) => void orWhereRaw( + forEach((criterion: string) => void orWhereRaw( '"event_tags" @> ?', [ JSON.stringify([[filterName[1], criterion]]) as any, @@ -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,13 +190,12 @@ export class EventRepository implements IEventRepository { .ignore() } - public upsert(event: Event): Promise { debug('upserting event: %o', event) const toJSON = (input: any) => JSON.stringify(input) - const row = applySpec({ + const row = applySpec({ event_id: pipe(prop('id'), toBuffer), event_pubkey: pipe(prop('pubkey'), toBuffer), event_created_at: prop('created_at'), @@ -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') diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts new file mode 100644 index 000000000..e493c4252 --- /dev/null +++ b/src/repositories/invoice-repository.ts @@ -0,0 +1,132 @@ +import { + always, + applySpec, + ifElse, + is, + isNil, + omit, + pipe, + prop, + propSatisfies, + toString, +} from 'ramda' + +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 confirmInvoice( + invoiceId: string, + amountPaid: bigint, + confirmedAt: Date, + client: DatabaseClient = this.dbClient, + ): Promise { + 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 { + const [dbInvoice] = await client('invoices') + .where('id', id) + .select() + + if (!dbInvoice) { + return + } + + return fromDBInvoice(dbInvoice) + } + + public async findPendingInvoices( + offset = 0, + limit = 10, + client: DatabaseClient = this.dbClient, + ): Promise { + const dbInvoices = await client('invoices') + .where('status', InvoiceStatus.PENDING) + .offset(offset) + .limit(limit) + .select() + + return dbInvoices.map(fromDBInvoice) + } + + public upsert( + invoice: Invoice, + client: DatabaseClient = this.dbClient + ): Promise { + debug('upserting invoice: %o', invoice) + + const row = applySpec({ + 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: 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: ifElse( + propSatisfies(isNil, 'expiresAt'), + always(undefined), + prop('expiresAt'), + ), + updated_at: always(new Date()), + created_at: ifElse( + propSatisfies(isNil, 'createdAt'), + always(undefined), + prop('createdAt'), + ), + })(invoice) + + debug('row: %o', row) + + const query = client('invoices') + .insert(row) + .onConflict('id') + .merge( + omit([ + 'id', + 'pubkey', + 'bolt11', + 'amount_requested', + 'unit', + 'description', + 'expires_at', + 'created_at', + ])(row) + ) + + return { + then: (onfulfilled: (value: number) => T1 | PromiseLike, onrejected: (reason: any) => T2 | PromiseLike) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), + catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), + toString: (): string => query.toString(), + } as Promise + } +} diff --git a/src/repositories/user-repository.ts b/src/repositories/user-repository.ts new file mode 100644 index 000000000..fedf40b43 --- /dev/null +++ b/src/repositories/user-repository.ts @@ -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 { + debug('find by pubkey: %s', pubkey) + const [dbuser] = await client('users') + .where('pubkey', toBuffer(pubkey)) + .select() + + if (!dbuser) { + return + } + + return fromDBUser(dbuser) + } + + public async upsert( + user: User, + client: DatabaseClient = this.dbClient, + ): Promise { + debug('upsert: %o', user) + + const date = new Date() + + const row = applySpec({ + 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('users') + .insert(row) + .onConflict('pubkey') + .merge( + omit([ + 'pubkey', + 'balance', + 'created_at', + ])(row) + ) + + return { + then: (onfulfilled: (value: number) => T1 | PromiseLike, onrejected: (reason: any) => T2 | PromiseLike) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), + catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), + toString: (): string => query.toString(), + } as Promise + } + + public async getBalanceByPubkey( + pubkey: Pubkey, + client: DatabaseClient = this.dbClient + ): Promise { + debug('get balance for pubkey: %s', pubkey) + + const [user] = await client('users') + .select('balance') + .where('pubkey', toBuffer(pubkey)) + .limit(1) + + if (!user) { + return 0n + } + + return BigInt(user.balance) + } +} diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts new file mode 100644 index 000000000..e38a67137 --- /dev/null +++ b/src/routes/callbacks/index.ts @@ -0,0 +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 debug = createLogger('routes-callbacks') + +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 + diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 000000000..8b864843c --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,17 @@ +import express from 'express' + +import callbacksRouter from './callbacks' +import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler' +import invoiceRouter from './invoices' +import { rootRequestHandler } from '../handlers/request-handlers/root-request-handler' + +const router = express.Router() + +router.get('/', rootRequestHandler) +router.get('/terms', getTermsRequestHandler) + +router.use('/invoices', invoiceRouter) +router.use('/callbacks', callbacksRouter) + + +export default router diff --git a/src/routes/invoices/index.ts b/src/routes/invoices/index.ts new file mode 100644 index 000000000..5c3085041 --- /dev/null +++ b/src/routes/invoices/index.ts @@ -0,0 +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 + .use((req, _res, next) => { + req['paymentsProcessor'] = createPaymentsProcessor() + next() + }) + .get('/', getInvoiceRequestHandler) + .post('/', urlencoded({ extended: true }), postInvoiceRequestHandler) + +export default invoiceRouter diff --git a/src/schemas/http-request-schemas.ts b/src/schemas/http-request-schemas.ts new file mode 100644 index 000000000..f2bd50281 --- /dev/null +++ b/src/schemas/http-request-schemas.ts @@ -0,0 +1,9 @@ +import Schema from 'joi' + +import { pubkeySchema } from './base-schema' + + +export const generateInvoiceSchema = Schema.object({ + pubkey: pubkeySchema.required(), + tosAccepted: Schema.valid('yes').required(), +}).unknown(false) diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts new file mode 100644 index 000000000..968a246ca --- /dev/null +++ b/src/services/payments-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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: ${toBech32('npub')(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()} +${invoice.expiresAt ? ` +⏳ 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 { + 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) + } +} diff --git a/src/utils/event.ts b/src/utils/event.ts index c504b3b61..c6580e4b7 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -1,16 +1,20 @@ import * as secp256k1 from '@noble/secp256k1' import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda' +import { createCipheriv, createHmac, getRandomValues } from 'crypto' +import cluster from 'cluster' -import { CanonicalEvent, DBEvent, Event } from '../@types/event' +import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event' import { EventId, Pubkey, Tag } from '../@types/base' import { EventKinds, EventTags } from '../constants/base' +import { EventKindsRange } from '../@types/settings' import { fromBuffer } from './transform' 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: Event): CanonicalEvent => [ +export const serializeEvent = (event: UnidentifiedEvent): CanonicalEvent => [ 0, event.pubkey, event.created_at, @@ -29,6 +33,12 @@ export const toNostrEvent: (event: DBEvent) => Event = applySpec({ sig: pipe(prop('event_signature') as () => Buffer, fromBuffer), }) +export const isEventKindOrRangeMatch = ({ kind }: Event) => + (item: EventKinds | EventKindsRange) => + typeof item === 'number' + ? item === kind + : kind >= item[0] && kind <= item[1] + export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix) @@ -149,16 +159,96 @@ export const isDelegatedEventValid = async (event: Event): Promise => { return secp256k1.schnorr.verify(delegation[3], token, delegation[1]) } -export const isEventIdValid = async (event: Event): Promise => { +export const getEventHash = async (event: Event | UnidentifiedEvent | UnsignedEvent): Promise => { const id = await secp256k1.utils.sha256(Buffer.from(JSON.stringify(serializeEvent(event)))) - return Buffer.from(id).toString('hex') === event.id + return Buffer.from(id).toString('hex') +} + +export const isEventIdValid = async (event: Event): Promise => { + return event.id === await getEventHash(event) } export const isEventSignatureValid = async (event: Event): Promise => { return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey) } +export const identifyEvent = async (event: UnidentifiedEvent): Promise => { + const id = await getEventHash(event) + + return { ...event, id } +} + +export const getPrivateKeyFromSecret = + (secret: string) => (data: string | Buffer): string => { + if (process.env.RELAY_PRIVATE_KEY) { + return process.env.RELAY_PRIVATE_KEY + } + + const hmac = createHmac('sha256', secret) + + hmac.update(typeof data === 'string' ? Buffer.from(data) : data) + + return hmac.digest().toString('hex') +} + +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 => { + 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 => { + return new Promise((resolve, reject) => { + if (!cluster.isWorker || typeof process.send === 'undefined') { + return 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 diff --git a/src/utils/http.ts b/src/utils/http.ts new file mode 100644 index 000000000..652714dde --- /dev/null +++ b/src/utils/http.ts @@ -0,0 +1,19 @@ +import { IncomingMessage } from 'http' + +import { Settings } from '../@types/settings' + +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) { + console.warn(`WARNING: Setting network.remote_ip_header is deprecated and will be removed in a future version. + Use network.remoteIpHeader instead.`) + header = settings.network['remote_ip_header'] as string + } else { + header = settings.network.remoteIpHeader as string + } + + const result = (request.headers[header] ?? request.socket.remoteAddress) as string + + return result.split(',')[0] +} diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 6f5e8afd6..a0971e266 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,12 +1,15 @@ import { EndOfStoredEventsNotice, + IncomingEventMessage, + IncomingRelayedEventMessage, MessageType, NoticeMessage, OutgoingMessage, + SubscribeMessage, } from '../@types/messages' -import { Event } from '../@types/event' +import { Event, RelayedEvent } from '../@types/event' +import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { EventId } from '../@types/base' -import { SubscriptionId } from '../@types/subscription' export const createNoticeMessage = (notice: string): NoticeMessage => { return [MessageType.NOTICE, notice] @@ -30,3 +33,19 @@ export const createEndOfStoredEventsNoticeMessage = ( export const createCommandResult = (eventId: EventId, successful: boolean, message: string) => { return [MessageType.OK, eventId, successful, message] } + +export const createSubscriptionMessage = ( + subscriptionId: SubscriptionId, + filters: SubscriptionFilter[] +): SubscribeMessage => { + return [MessageType.REQ, subscriptionId, ...filters] as any +} + +export const createRelayedEventMessage = + (event: RelayedEvent, secret?: string): IncomingRelayedEventMessage | IncomingEventMessage => { + if (!secret) { + return [MessageType.EVENT, event] + } + + return [MessageType.EVENT, event, secret] + } diff --git a/src/utils/misc.ts b/src/utils/misc.ts new file mode 100644 index 000000000..3ba433b12 --- /dev/null +++ b/src/utils/misc.ts @@ -0,0 +1,5 @@ +export const delayMs = (ms: number): Promise => new Promise( + (resolve) => { + setTimeout(resolve, ms) + } +) diff --git a/src/utils/settings.ts b/src/utils/settings.ts index ebfd3ba71..e9e226e71 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,26 +1,37 @@ import fs from 'fs' -import { homedir } from 'os' -import { join } from 'path' +import yaml from 'js-yaml' + +import { extname, join } from 'path' import { mergeDeepRight } from 'ramda' import { createLogger } from '../factories/logger-factory' -import { ISettings } from '../@types/settings' -import settingsSampleJson from '../../settings.sample.json' +import { Settings } from '../@types/settings' const debug = createLogger('settings') +export enum SettingsFileTypes { + yaml = 'yaml', + json = 'json', +} + export class SettingsStatic { - static _settings: ISettings + static _settings: Settings | undefined - public static getSettingsFilePath(filename = 'settings.json') { - return join( - process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'), - filename - ) + public static getSettingsFileBasePath(): string { + return process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr') } - public static loadSettings(path: string) { - debug('loading settings from %s', path) + public static getDefaultSettingsFilePath(): string { + return join(process.cwd(), 'resources', 'default-settings.yaml') + } + + public static loadAndParseYamlFile(path: string): Settings { + const defaultSettingsFileContent = fs.readFileSync(path, { encoding: 'utf-8' }) + const defaults = yaml.load(defaultSettingsFileContent) as Settings + return defaults + } + + public static loadAndParseJsonFile(path: string) { return JSON.parse( fs.readFileSync( path, @@ -29,39 +40,95 @@ export class SettingsStatic { ) } - public static createSettings(): ISettings { + public static settingsFileType(path: string): SettingsFileTypes | undefined { + const files: string[] = fs.readdirSync(path) + const filteredFile = files.find(fn => fn.startsWith('settings')) + if (filteredFile) { + const extension = extname(filteredFile).substring(1) + return SettingsFileTypes[extension] + } + } + + public static loadSettings(path: string, fileType: SettingsFileTypes) { + debug('loading settings from %s', path) + + switch (fileType) { + case SettingsFileTypes.json: { + console.warn('settings.json is deprecated, please use a yaml file based on resources/default-settings.yaml') + return SettingsStatic.loadAndParseJsonFile(path) + } + case SettingsFileTypes.yaml: { + return SettingsStatic.loadAndParseYamlFile(path) + } + default: { + throw new Error('settings file was missing or did not contain .yaml or .json extensions.') + } + } + } + + public static createSettings(): Settings { if (SettingsStatic._settings) { return SettingsStatic._settings } debug('creating settings') - const path = SettingsStatic.getSettingsFilePath() - const defaults = settingsSampleJson as ISettings - try { - if (fs.existsSync(path)) { + const basePath = SettingsStatic.getSettingsFileBasePath() + if (!fs.existsSync(basePath)) { + fs.mkdirSync(basePath) + } + const defaultsFilePath = SettingsStatic.getDefaultSettingsFilePath() + const fileType = SettingsStatic.settingsFileType(basePath) + const settingsFilePath = join(basePath, `settings.${fileType}`) + + const defaults = SettingsStatic.loadSettings(defaultsFilePath, SettingsFileTypes.yaml) + + try { + if (fileType) { SettingsStatic._settings = mergeDeepRight( defaults, - SettingsStatic.loadSettings(path) + SettingsStatic.loadSettings(settingsFilePath, fileType) ) } else { - SettingsStatic.saveSettings(path, defaults) + SettingsStatic.saveSettings(basePath, defaults) SettingsStatic._settings = mergeDeepRight({}, defaults) } + if (typeof SettingsStatic._settings === 'undefined') { + throw new Error('Unable to set settings') + } + return SettingsStatic._settings } catch (error) { - debug('error reading config file at %s: %o', path, error) + debug('error reading config file at %s: %o', settingsFilePath, error) return defaults } } - 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( - path, - JSON.stringify(settings, null, 2), - { encoding: 'utf-8' } + join(path, 'settings.yaml'), + yaml.dump(settings), + { 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), + ] + } } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 71aed3bca..a5b8ccf96 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -1,5 +1,85 @@ +import { always, applySpec, ifElse, is, isNil, path, pipe, prop, propSatisfies } from 'ramda' +import { bech32 } from 'bech32' + +import { Invoice } from '../@types/invoice' +import { User } from '../@types/user' + export const toJSON = (input: any) => JSON.stringify(input) export const toBuffer = (input: any) => Buffer.from(input, 'hex') export const fromBuffer = (input: Buffer) => input.toString('hex') + +export const toBigInt = (input: string): bigint => BigInt(input) + +export const fromBigInt = (input: bigint) => input.toString() + +export const fromDBInvoice = applySpec({ + id: prop('id') as () => string, + pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer), + bolt11: prop('bolt11'), + 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'), + confirmedAt: prop('confirmed_at'), + expiresAt: prop('expires_at'), + updatedAt: prop('updated_at'), + createdAt: prop('created_at'), +}) + +export const fromDBUser = applySpec({ + 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( + bech32.fromWords(words).slice(0, 32) + ).toString('hex') +} + +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({ + id: prop('id'), + pubkey: prop('internalId'), + bolt11: path(['invoice', 'request']), + amountRequested: pipe(prop('amount') as () => string, toBigInt), + description: prop('description'), + unit: prop('unit'), + status: prop('status'), + expiresAt: ifElse( + propSatisfies(is(String), 'expiresAt'), + pipe(prop('expiresAt'), toDate), + always(null), + ), + confirmedAt: ifElse( + propSatisfies(is(String), 'confirmedAt'), + pipe(prop('confirmedAt'), toDate), + always(null), + ), + createdAt: ifElse( + propSatisfies(is(String), 'createdAt'), + pipe(prop('createdAt'), toDate), + always(null), + ), + rawRespose: toJSON, +}) diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index c9cca0922..1a268eb44 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -20,7 +20,7 @@ services: DEBUG: "" volumes: - ../../package.json:/code/package.json - - ../../settings.sample.json:/code/settings.sample.json + - ../../resources:/code/resources - ../../src:/code/src - ../../test/integration:/code/test/integration - ../../cucumber.js:/code/cucumber.js @@ -57,7 +57,7 @@ services: retries: 0 cache-test: image: redis:7.0.5-alpine3.16 - command: redis-server --save 20 1 --loglevel warning --requirepass nostr_ts_relay_test + command: redis-server --loglevel warning --requirepass nostr_ts_relay_test networks: - nostream-test restart: always @@ -77,7 +77,7 @@ services: entrypoint: - sh - -c - - 'cd code && npm install --no-save --quiet knex@2.3.0 pg@8.8.0 && npx knex migrate:latest' + - 'cd code && npm install --no-save --quiet knex@2.4.0 pg@8.8.0 && npx knex migrate:latest' volumes: - ../../migrations:/code/migrations - ../../knexfile.js:/code/knexfile.js diff --git a/test/integration/features/nip-01/nip-01.feature.ts b/test/integration/features/nip-01/nip-01.feature.ts index 0bf2d35af..e6ba20636 100644 --- a/test/integration/features/nip-01/nip-01.feature.ts +++ b/test/integration/features/nip-01/nip-01.feature.ts @@ -170,10 +170,7 @@ Then(/(\w+) receives a text_note event from (\w+) with content "([^"]+?)"/, asyn const ws = this.parameters.clients[name] as WebSocket const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] const receivedEvent = await waitForNextEvent(ws, subscription.name, content) - console.log('receivedEvent', receivedEvent) expect(receivedEvent.kind).to.equal(1) - console.log('name', name, this.parameters.identities[name].pubkey) - console.log('author', author, this.parameters.identities[author].pubkey) expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey) expect(receivedEvent.content).to.equal(content) }) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index f45d8bc78..44df25f8a 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -13,12 +13,12 @@ import { fromEvent, map, Observable, ReplaySubject, Subject, takeUntil } from 'r import WebSocket, { MessageEvent } from 'ws' import { connect, createIdentity, createSubscription, sendEvent } from './helpers' +import { getMasterDbClient, getReadReplicaDbClient } from '../../../src/database/client' import { AppWorker } from '../../../src/app/worker' import { CacheClient } from '../../../src/@types/cache' import { DatabaseClient } from '../../../src/@types/base' import { Event } from '../../../src/@types/event' import { getCacheClient } from '../../../src/cache/client' -import { getMasterDbClient } from '../../../src/database/client' import { SettingsStatic } from '../../../src/utils/settings' import { workerFactory } from '../../../src/factories/worker-factory' @@ -27,6 +27,7 @@ export const isDraft = Symbol('draft') let worker: AppWorker let dbClient: DatabaseClient +let rrDbClient: DatabaseClient let cacheClient: CacheClient export const streams = new WeakMap>() @@ -35,9 +36,8 @@ BeforeAll({ timeout: 1000 }, async function () { process.env.RELAY_PORT = '18808' cacheClient = getCacheClient() dbClient = getMasterDbClient() + rrDbClient = getReadReplicaDbClient() await dbClient.raw('SELECT 1=1') - await cacheClient.connect() - await cacheClient.ping() const settings = SettingsStatic.createSettings() @@ -45,6 +45,8 @@ BeforeAll({ timeout: 1000 }, async function () { assocPath( ['limits', 'event', 'createdAt', 'maxPositiveDelta'], 0), assocPath( ['limits', 'message', 'rateLimits'], []), assocPath( ['limits', 'event', 'rateLimits'], []), + assocPath( ['limits', 'invoice', 'rateLimits'], []), + assocPath( ['limits', 'connection', 'rateLimits'], []), )(settings) as any worker = workerFactory() @@ -52,7 +54,9 @@ BeforeAll({ timeout: 1000 }, async function () { }) AfterAll(async function() { - worker.close(async () => Promise.all([cacheClient.disconnect(), dbClient.destroy()])) + worker.close(async () => { + await Promise.all([cacheClient.disconnect(), dbClient.destroy(), rrDbClient.destroy()]) + }) }) Before(function () { diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 27739555f..b44c1ce78 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -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', () => { diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index 7f0ada338..1a5002dc1 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -3,24 +3,34 @@ 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 { SettingsStatic } from '../../../src/utils/settings' import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter' import { webSocketAdapterFactory } from '../../../src/factories/websocket-adapter-factory' describe('webSocketAdapterFactory', () => { let onStub: Sinon.SinonStub + let createSettingsStub: Sinon.SinonStub beforeEach(() => { onStub = Sinon.stub() + createSettingsStub = Sinon.stub(SettingsStatic, 'createSettings') }) afterEach(() => { + createSettingsStub.restore() onStub.reset() }) it('returns a WebSocketAdapter', () => { + createSettingsStub.returns({ + network: { + remoteIpHeader: 'remoteIpHeader', + }, + }) const eventRepository: IEventRepository = {} as any + const userRepository: IUserRepository = {} as any const client: WebSocket = { on: onStub, @@ -37,7 +47,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) }) }) diff --git a/test/unit/factories/worker-factory.spec.ts b/test/unit/factories/worker-factory.spec.ts index a4c8ba1d3..95daaa0a2 100644 --- a/test/unit/factories/worker-factory.spec.ts +++ b/test/unit/factories/worker-factory.spec.ts @@ -4,23 +4,37 @@ import Sinon from 'sinon' import * as databaseClientModule from '../../../src/database/client' import { AppWorker } from '../../../src/app/worker' +import { SettingsStatic } from '../../../src/utils/settings' import { workerFactory } from '../../../src/factories/worker-factory' + describe('workerFactory', () => { + let createSettingsStub: Sinon.SinonStub let getMasterDbClientStub: Sinon.SinonStub let getReadReplicaDbClientStub: Sinon.SinonStub beforeEach(() => { + createSettingsStub = Sinon.stub(SettingsStatic, 'createSettings') getMasterDbClientStub = Sinon.stub(databaseClientModule, 'getMasterDbClient') getReadReplicaDbClientStub = Sinon.stub(databaseClientModule, 'getReadReplicaDbClient') }) afterEach(() => { - getMasterDbClientStub.restore() getReadReplicaDbClientStub.restore() + getMasterDbClientStub.restore() + createSettingsStub.restore() }) it('returns an AppWorker', () => { + createSettingsStub.returns({ + info: { + relay_url: 'url', + }, + network: { + + }, + }) + const worker = workerFactory() expect(worker).to.be.an.instanceOf(AppWorker) worker.close() diff --git a/test/unit/handlers/delegated-event-message-handler.spec.ts b/test/unit/handlers/delegated-event-message-handler.spec.ts index b8a857e2a..d32e9ac6e 100644 --- a/test/unit/handlers/delegated-event-message-handler.spec.ts +++ b/test/unit/handlers/delegated-event-message-handler.spec.ts @@ -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) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index fe626d39c..da11f3e7a 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -3,13 +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' @@ -18,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 @@ -51,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, @@ -67,6 +74,7 @@ describe('EventMessageHandler', () => { handler = new EventMessageHandler( webSocket as any, strategyFactoryStub, + userRepository, () => ({}) as any, () => ({ hit: async () => false }) ) @@ -112,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) @@ -155,7 +172,7 @@ describe('EventMessageHandler', () => { describe('canAcceptEvent', () => { let eventLimits: EventLimits - let settings: ISettings + let settings: Settings let clock: SinonFakeTimers beforeEach(() => { @@ -174,6 +191,7 @@ describe('EventMessageHandler', () => { whitelist: [], }, pubkey: { + minBalance: 0n, minLeadingZeroBits: 0, blacklist: [], whitelist: [], @@ -190,6 +208,7 @@ describe('EventMessageHandler', () => { handler = new EventMessageHandler( {} as any, () => null, + userRepository, () => settings, () => ({ hit: async () => false }) ) @@ -241,8 +260,8 @@ describe('EventMessageHandler', () => { describe('content', () => { describe('maxLength', () => { - it('returns undefined if maxLength is zero', () => { - eventLimits.content.maxLength = 0 + it('returns undefined if maxLength is disabled', () => { + eventLimits.content = [{ maxLength: 0 }] expect( (handler as any).canAcceptEvent(event) @@ -250,7 +269,62 @@ describe('EventMessageHandler', () => { }) it('returns undefned if content is not too long', () => { - eventLimits.content.maxLength = 100 + eventLimits.content = [{ maxLength: 1 }] + event.content = 'x'.repeat(1) + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined + }) + + it('returns undefined if kind does not match', () => { + eventLimits.content = [{ kinds: [EventKinds.SET_METADATA], maxLength: 1 }] + event.content = 'x' + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined + }) + + it('returns undefined if kind matches but content is short', () => { + eventLimits.content = [{ kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }] + event.content = 'x' + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined + }) + + it('returns reason if kind matches but content is too long', () => { + eventLimits.content = [{ kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }] + event.content = 'xx' + + expect( + (handler as any).canAcceptEvent(event) + ).to.equal('rejected: content is longer than 1 bytes') + }) + + it('returns reason if content is too long', () => { + eventLimits.content = [{ maxLength: 1 }] + event.content = 'x'.repeat(2) + + expect( + (handler as any).canAcceptEvent(event) + ).to.equal('rejected: content is longer than 1 bytes') + }) + }) + + describe('maxLength (deprecated)', () => { + it('returns undefined if maxLength is zero', () => { + eventLimits.content = { maxLength: 0 } + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined + }) + + it('returns undefined if content is short', () => { + eventLimits.content = { maxLength: 100 } event.content = 'x'.repeat(100) expect( @@ -259,12 +333,48 @@ describe('EventMessageHandler', () => { }) it('returns reason if content is too long', () => { - eventLimits.content.maxLength = 100 - event.content = 'x'.repeat(101) + eventLimits.content = { maxLength: 1 } + event.content = 'xx' expect( (handler as any).canAcceptEvent(event) - ).to.equal('rejected: content is longer than 100 bytes') + ).to.equal('rejected: content is longer than 1 bytes') + }) + + it('returns undefined if kind matches and content is short', () => { + eventLimits.content = { kinds: [EventKinds.TEXT_NOTE], maxLength: 1 } + event.content = 'x' + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined + }) + + it('returns undefined if kind does not match and content is too long', () => { + eventLimits.content = { kinds: [EventKinds.SET_METADATA], maxLength: 1 } + event.content = 'xx' + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined + }) + + it('returns reason if content is too long', () => { + eventLimits.content = { maxLength: 1 } + event.content = 'xx' + + expect( + (handler as any).canAcceptEvent(event) + ).to.equal('rejected: content is longer than 1 bytes') + }) + + it('returns undefined if content is not set', () => { + eventLimits.content = undefined + event.content = 'xx' + + expect( + (handler as any).canAcceptEvent(event) + ).to.be.undefined }) }) @@ -547,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 @@ -569,6 +680,7 @@ describe('EventMessageHandler', () => { handler = new EventMessageHandler( webSocket, () => null, + userRepository, () => settings, () => ({ hit: rateLimiterHitStub }) ) @@ -666,12 +778,12 @@ describe('EventMessageHandler', () => { rate: 1, }, { - kinds: [0], + kinds: [1], period: 60000, rate: 2, }, { - kinds: [[10, 20]], + kinds: [[0, 3]], period: 86400000, rate: 3, }, @@ -689,7 +801,7 @@ describe('EventMessageHandler', () => { } ) expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly( - 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[0]', + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[1]', 1, { period: 60000, @@ -697,7 +809,7 @@ describe('EventMessageHandler', () => { } ) expect(rateLimiterHitStub.thirdCall).to.have.been.calledWithExactly( - 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[10,20]]', + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[0,3]]', 1, { period: 86400000, @@ -729,7 +841,14 @@ describe('EventMessageHandler', () => { const actualResult = await (handler as any).isRateLimited(event) - expect(rateLimiterHitStub).to.have.been.calledThrice + expect(rateLimiterHitStub).to.have.been.calledOnceWithExactly( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000', + 1, + { + period: 60000, + rate: 1, + }, + ) expect(actualResult).to.be.false }) @@ -745,19 +864,33 @@ describe('EventMessageHandler', () => { rate: 2, }, { - kinds: [[10, 20]], - period: 86400000, + kinds: [[0, 5]], + period: 180, rate: 3, }, ] rateLimiterHitStub.onFirstCall().resolves(false) rateLimiterHitStub.onSecondCall().resolves(true) - rateLimiterHitStub.onThirdCall().resolves(false) const actualResult = await (handler as any).isRateLimited(event) - expect(rateLimiterHitStub).to.have.been.calledThrice + expect(rateLimiterHitStub.firstCall).to.have.been.calledWithExactly( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000', + 1, + { + period: 60000, + rate: 1, + }, + ) + expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:180:[[0,5]]', + 1, + { + period: 180, + rate: 3, + }, + ) expect(actualResult).to.be.true }) }) diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 05ab195d6..45b6fd0da 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -177,23 +177,6 @@ describe('SubscribeMessageHandler', () => { ) }) - it('ends event stream if aborted', async () => { - isClientSubscribedToEventStub.returns(always(true)) - - const abort = () => (handler as IAbortable).abort() - const fetch = () => (handler as any).fetchAndSend(subscriptionId, filters) - - const promise = fetch() - - const closeSpy = sandbox.spy() - stream.once('close', closeSpy) - - abort() - - await expect(promise).to.eventually.be.rejectedWith(Error, 'The operation was aborted') - expect(closeSpy).to.have.been.called - }) - it('ends event stream if error occurs', async () => { const error = new Error('mistakes were made') isClientSubscribedToEventStub.returns(always(true)) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index af008d285..fcd65ff5b 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -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') }) }) }) diff --git a/test/unit/utils/http.spec.ts b/test/unit/utils/http.spec.ts new file mode 100644 index 000000000..2a7ef9bba --- /dev/null +++ b/test/unit/utils/http.spec.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai' +import { IncomingMessage } from 'http' + +import { getRemoteAddress } from '../../../src/utils/http' + +describe('getRemoteAddress', () => { + const header = 'x-forwarded-for' + const socketAddress = 'socket-address' + const address = 'address' + + let request: IncomingMessage + + beforeEach(() => { + request = { + headers: { + [header]: address, + }, + socket: { + remoteAddress: socketAddress, + }, + } as any + }) + + it('returns address using network.remote_ip_address when set', () => { + expect( + getRemoteAddress( + request, + { network: { 'remote_ip_header': header } } as any, + ) + ).to.equal(address) + }) + + it('returns address using network.remoteIpAddress when set', () => { + expect( + getRemoteAddress( + request, + { network: { remoteIpHeader: header } } as any, + ) + ).to.equal(address) + }) + + it('returns address from socket when header is unset', () => { + expect( + getRemoteAddress( + request, + { network: { } } as any, + ) + ).to.equal(socketAddress) + }) +}) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index b42baee09..c97872d42 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -1,10 +1,9 @@ import { expect } from 'chai' import fs from 'fs' -import { homedir } from 'os' import { join } from 'path' import Sinon from 'sinon' -import { SettingsStatic } from '../../../src/utils/settings' +import { SettingsFileTypes, SettingsStatic } from '../../../src/utils/settings' describe('SettingsStatic', () => { describe('.getSettingsFilePath', () => { @@ -19,22 +18,108 @@ describe('SettingsStatic', () => { process.env = originalEnv }) - it('returns string ending with settings.json by default', () => { - expect(SettingsStatic.getSettingsFilePath()).to.be.a('string').and.to.match(/settings\.json$/) + it('returns string ending with .nostr/ by default', () => { + expect(SettingsStatic.getSettingsFileBasePath()).to.be.a('string').and.to.match(/.nostr/) + }) + + it('returns path begins with user\'s home dir by default', () => { + expect(SettingsStatic.getSettingsFileBasePath()).to.be.a('string').and.equal(`${join(process.cwd(), '.nostr')}`) }) - it('returns string ending with given string', () => { - expect(SettingsStatic.getSettingsFilePath('ending')).to.be.a('string').and.to.match(/ending$/) + it('returns path with NOSTR_CONFIG_DIR if set', () => { + process.env.NOSTR_CONFIG_DIR = '/some/path/' + + expect(SettingsStatic.getSettingsFileBasePath()).to.be.a('string').and.equal('/some/path/') + }) + }) + + describe('.getDefaultSettingsFilePath', () => { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + originalEnv = process.env + process.env = {} + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('returns string ending with settings.json by default', () => { + expect(SettingsStatic.getDefaultSettingsFilePath()).to.be.a('string').and.to.match(/settings\.yaml$/) }) it('returns path begins with user\'s home dir by default', () => { - expect(SettingsStatic.getSettingsFilePath()).to.be.a('string').and.equal(`${join(homedir(), '.nostr')}/settings.json`) + expect(SettingsStatic.getDefaultSettingsFilePath()).to.be.a('string').and.equal(`${join(process.cwd(), '/resources')}/default-settings.yaml`) }) + }) - it('returns path with NOSTR_CONFIG_DIR if set', () => { - process.env.NOSTR_CONFIG_DIR = '/some/path' + describe('.loadAndParseYamlFile', () => { + let readFileSyncStub: Sinon.SinonStub + + beforeEach(() => { + readFileSyncStub = Sinon.stub(fs, 'readFileSync') + }) - expect(SettingsStatic.getSettingsFilePath()).to.be.a('string').and.equal('/some/path/settings.json') + afterEach(() => { + readFileSyncStub.restore() + }) + + it('loads and parses yaml file from given path', () => { + readFileSyncStub.returns('"content"') + + expect(SettingsStatic.loadAndParseYamlFile('/some/path/file.yaml')).to.equal('content') + + expect(readFileSyncStub).to.have.been.calledOnceWithExactly( + '/some/path/file.yaml', + { encoding: 'utf-8' } + ) + }) + }) + + describe('.loadAndParseJsonFile', () => { + let readFileSyncStub: Sinon.SinonStub + + beforeEach(() => { + readFileSyncStub = Sinon.stub(fs, 'readFileSync') + }) + + afterEach(() => { + readFileSyncStub.restore() + }) + + it('loads and parses json file from given path', () => { + readFileSyncStub.returns('"content"') + + expect(SettingsStatic.loadAndParseJsonFile('/some/path/file.json')).to.equal('content') + + expect(readFileSyncStub).to.have.been.calledOnceWithExactly( + '/some/path/file.json', + { encoding: 'utf-8' } + ) + }) + }) + + describe('.settingsFileType', () => { + let readFileSyncStub: Sinon.SinonStub + + beforeEach(() => { + readFileSyncStub = Sinon.stub(fs, 'readFileSync') + }) + + afterEach(() => { + readFileSyncStub.restore() + }) + + it('gets file type by looking for settings file in config dir', () => { + readFileSyncStub.returns('{\n"key": "value"\n}') + + expect(SettingsStatic.loadAndParseJsonFile('/some/path/file.json')).to.have.property('key', 'value') + + expect(readFileSyncStub).to.have.been.calledOnceWithExactly( + '/some/path/file.json', + { encoding: 'utf-8' }, + ) }) }) @@ -52,7 +137,7 @@ describe('SettingsStatic', () => { it('loads settings from given path', () => { readFileSyncStub.returns('"content"') - expect(SettingsStatic.loadSettings('/some/path')).to.equal('content') + expect(SettingsStatic.loadSettings('/some/path', SettingsFileTypes.yaml)).to.equal('content') expect(readFileSyncStub).to.have.been.calledOnceWithExactly( '/some/path', @@ -63,7 +148,11 @@ describe('SettingsStatic', () => { describe('.createSettings', () => { let existsSyncStub: Sinon.SinonStub - let getSettingsFilePathStub: Sinon.SinonStub + let mkdirSyncStub: Sinon.SinonStub + let readdirSyncStub: Sinon.SinonStub + let getSettingsFileBasePathStub: Sinon.SinonStub + let getDefaultSettingsFilePathStub: Sinon.SinonStub + let settingsFileTypeStub: Sinon.SinonStub let saveSettingsStub: Sinon.SinonStub let loadSettingsStub: Sinon.SinonStub @@ -75,7 +164,11 @@ describe('SettingsStatic', () => { sandbox = Sinon.createSandbox() existsSyncStub = sandbox.stub(fs, 'existsSync') - getSettingsFilePathStub = sandbox.stub(SettingsStatic, 'getSettingsFilePath') + mkdirSyncStub = sandbox.stub(fs, 'mkdirSync') + readdirSyncStub = sandbox.stub(fs, 'readdirSync') + getSettingsFileBasePathStub = sandbox.stub(SettingsStatic, 'getSettingsFileBasePath') + getDefaultSettingsFilePathStub = sandbox.stub(SettingsStatic, 'getDefaultSettingsFilePath') + settingsFileTypeStub = sandbox.stub(SettingsStatic, 'settingsFileType') saveSettingsStub = sandbox.stub(SettingsStatic, 'saveSettings') loadSettingsStub = sandbox.stub(SettingsStatic, 'loadSettings') }) @@ -84,63 +177,58 @@ describe('SettingsStatic', () => { sandbox.restore() }) - it('creates settings from default if settings file is missing', () => { - getSettingsFilePathStub.returns('/some/path/settings.json') + it('creates settings from defaults if settings file is missing', () => { + getSettingsFileBasePathStub.returns('/some/path/settings.yaml') existsSyncStub.returns(false) + mkdirSyncStub.returns(true) + readdirSyncStub.returns(['file.yaml']) + loadSettingsStub.returns({}) expect(SettingsStatic.createSettings()).to.be.an('object') - expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json') - expect(getSettingsFilePathStub).to.have.been.calledOnce + expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.yaml') + expect(getSettingsFileBasePathStub).to.have.been.calledOnce expect(saveSettingsStub).to.have.been.calledOnceWithExactly( - '/some/path/settings.json', + '/some/path/settings.yaml', Sinon.match.object, ) - expect(loadSettingsStub).not.to.have.been.called + expect(loadSettingsStub).to.have.been.called }) it('returns default settings if saving settings file throws', () => { const error = new Error('mistakes were made') - getSettingsFilePathStub.returns('/some/path/settings.json') + getSettingsFileBasePathStub.returns('/some/path/settings.json') saveSettingsStub.throws(error) existsSyncStub.returns(false) + readdirSyncStub.returns(['file.yaml']) + loadSettingsStub.returns({}) expect(SettingsStatic.createSettings()).to.be.an('object') expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json') - expect(getSettingsFilePathStub).to.have.been.calledOnce + expect(getSettingsFileBasePathStub).to.have.been.calledOnce expect(saveSettingsStub).to.have.been.calledOnceWithExactly( '/some/path/settings.json', Sinon.match.object, ) - expect(loadSettingsStub).not.to.have.been.called + expect(loadSettingsStub).to.have.been.called }) - it('loads settings from file if settings file is exists', () => { - loadSettingsStub.returns({}) - getSettingsFilePathStub.returns('/some/path/settings.json') + it('loads settings from file if settings file exists', () => { + loadSettingsStub.returns({ test: 'value' }) + getSettingsFileBasePathStub.returns('/some/path/settings.yaml') + getDefaultSettingsFilePathStub.returns('/some/path/settings.yaml') existsSyncStub.returns(true) + readdirSyncStub.returns(['settings.yaml']) + settingsFileTypeStub.returns('yaml') - expect(SettingsStatic.createSettings()).to.be.an('object') - - expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json') - expect(getSettingsFilePathStub).to.have.been.calledOnce - expect(saveSettingsStub).not.to.have.been.called - expect(loadSettingsStub).to.have.been.calledOnceWithExactly('/some/path/settings.json') - }) - - it('returns defaults if loading settings file throws', () => { - const error = new Error('mistakes were made') - loadSettingsStub.throws(error) - getSettingsFilePathStub.returns('/some/path/settings.json') - existsSyncStub.returns(true) expect(SettingsStatic.createSettings()).to.be.an('object') - expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json') - expect(getSettingsFilePathStub).to.have.been.calledOnce + expect(existsSyncStub).to.have.been.calledWithExactly('/some/path/settings.yaml') + expect(getSettingsFileBasePathStub).to.have.been.calledOnce expect(saveSettingsStub).not.to.have.been.called - expect(loadSettingsStub).to.have.been.calledOnceWithExactly('/some/path/settings.json') + expect(loadSettingsStub).to.have.been.calledWithExactly('/some/path/settings.yaml', 'yaml') }) it('returns cached settings if set', () => { @@ -149,7 +237,7 @@ describe('SettingsStatic', () => { expect(SettingsStatic.createSettings()).to.equal(cachedSettings) - expect(getSettingsFilePathStub).not.to.have.been.calledOnce + expect(getSettingsFileBasePathStub).not.to.have.been.calledOnce expect(existsSyncStub).not.to.have.been.called expect(saveSettingsStub).not.to.have.been.called expect(loadSettingsStub).not.to.have.been.called @@ -168,11 +256,11 @@ describe('SettingsStatic', () => { }) it('saves settings to given path', () => { - SettingsStatic.saveSettings('/some/path/settings.json', {key: 'value'} as any) + SettingsStatic.saveSettings('/some/path', {key: 'value'} as any) expect(writeFileSyncStub).to.have.been.calledOnceWithExactly( - '/some/path/settings.json', - '{\n "key": "value"\n}', + '/some/path/settings.yaml', + Sinon.match.string, { encoding: 'utf-8' } ) }) diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts index bee006862..29dd16980 100644 --- a/test/unit/utils/sliding-window-rate-limiter.spec.ts +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -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) }) diff --git a/tsconfig.json b/tsconfig.json index 2839ea8a8..b575c4bde 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es6", + "target": "ESNext", "outDir": "./dist", "moduleResolution": "Node", "types": ["node", "mocha", "@cucumber/cucumber"],