mirror of
https://github.com/Cameri/nostream.git
synced 2025-05-31 10:09:11 +02:00
feat: add pay-to-relay
This commit is contained in:
parent
779f7b7fe6
commit
2618a4d2dc
@ -1,19 +1,70 @@
|
||||
{
|
||||
"info": {
|
||||
"relay_url": "wss://nostream.localtest.me",
|
||||
"name": "nostream.localtest.me",
|
||||
"description": "A nostr relay written in TypeScript.",
|
||||
"pubkey": "replace-with-your-pubkey",
|
||||
"relay_url": "wss://nostr-relay-dev.wlvs.space",
|
||||
"name": "nostr-relay-dev.wlvs.space",
|
||||
"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://nostr-relay-dev.wlvs.space/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
|
||||
}
|
||||
|
@ -47,8 +47,8 @@ Running `nostream` for the first time creates the settings file in `<project_roo
|
||||
| info.description | Public description of your relay. (e.g. Toronto Bitcoin Group Public Relay) |
|
||||
| info.pubkey | Relay operator's Nostr pubkey in hex format. |
|
||||
| info.contact | Relay operator's contact. (e.g. mailto:operator@relay-your-domain.com) |
|
||||
| network.max_payload_size | Maximum number of bytes accepted per WebSocket frame |
|
||||
| network.remote_ip_header | HTTP header from proxy containing IP address from client. |
|
||||
| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
|
||||
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
|
||||
| workers.count | Number of workers to spin up to handle incoming connections. |
|
||||
| | Spin workers as many CPUs are available when set to zero. Defaults to zero. |
|
||||
| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. |
|
||||
@ -61,7 +61,8 @@ Running `nostream` for the first time creates the settings file in `<project_roo
|
||||
| limits.event.pubkey.blacklist | List of public keys to always reject. Public keys in this list will not be able to post to this relay. |
|
||||
| limits.event.createdAt.maxPositiveDelta | Maximum number of seconds an event's `created_at` can be in the future. Defaults to 900 (15 minutes). Disabled when set to zero. |
|
||||
| limits.event.createdAt.minNegativeDelta | Maximum number of secodns an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
|
||||
| limits.event.content.maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
|
||||
| limits.event.content[].kinds | List of event kinds to apply limit. Use `[min, max]` for ranges. Optional. |
|
||||
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
|
||||
| limits.event.rateLimits[].kinds | List of event kinds rate limited. Use `[min, max]` for ranges. Optional. |
|
||||
| limits.event.rateLimits[].period | Rate limiting period in milliseconds. |
|
||||
| limits.event.rateLimits[].rate | Maximum number of events during period. |
|
||||
|
@ -28,6 +28,8 @@ ENV DB_PASSWORD=nostr-ts-relay
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache --update git
|
||||
|
||||
ADD resources /app/resources
|
||||
|
||||
COPY --from=build /build/dist .
|
||||
|
||||
RUN npm install --omit=dev --quiet
|
||||
|
@ -3,6 +3,7 @@ services:
|
||||
build: .
|
||||
container_name: nostr-ts-relay
|
||||
environment:
|
||||
SECRET: changeme
|
||||
RELAY_PORT: 8008
|
||||
NOSTR_CONFIG_DIR: /home/node/
|
||||
# Master
|
||||
@ -37,6 +38,8 @@ services:
|
||||
# DEBUG: "primary:*"
|
||||
# DEBUG: "worker:*"
|
||||
# DEBUG: "knex:query"
|
||||
env_file:
|
||||
- test.env
|
||||
user: node:node
|
||||
volumes:
|
||||
- ${PWD}/.nostr:/home/node/
|
||||
|
@ -0,0 +1,12 @@
|
||||
exports.up = async function (knex) {
|
||||
return knex.schema
|
||||
.raw(
|
||||
`CREATE UNIQUE INDEX pubkey_delegator_kind_idx
|
||||
ON events ( event_pubkey, event_delegator, event_kind );`,
|
||||
)
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema
|
||||
.raw('DROP INDEX IF EXISTS pubkey_delegator_kind_idx;')
|
||||
}
|
20
migrations/20230107_230900_create_invoices_table.js
Normal file
20
migrations/20230107_230900_create_invoices_table.js
Normal file
@ -0,0 +1,20 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.createTable('invoices', (table) => {
|
||||
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'])
|
||||
table.text('description')
|
||||
table.datetime('confirmed_at', { useTz: false, precision: 3 })
|
||||
table.datetime('expires_at', { useTz: false, precision: 3 })
|
||||
table.timestamp('updated_at', { useTz: false })
|
||||
table.timestamps(true, true, false)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.dropTable('invoices')
|
||||
}
|
1223
package-lock.json
generated
1223
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -78,6 +78,7 @@
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/debug": "4.1.7",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/node": "^17.0.24",
|
||||
"@types/pg": "^8.6.5",
|
||||
@ -109,8 +110,13 @@
|
||||
},
|
||||
"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",
|
||||
"express": "4.18.2",
|
||||
"helmet": "6.0.1",
|
||||
"joi": "17.7.0",
|
||||
"knex": "2.4.0",
|
||||
"pg": "8.8.0",
|
||||
|
166
resources/css/style.css
Normal file
166
resources/css/style.css
Normal file
@ -0,0 +1,166 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
216
resources/default-settings.json
Normal file
216
resources/default-settings.json
Normal file
@ -0,0 +1,216 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
}],
|
||||
"publication": [
|
||||
{
|
||||
"enabled": false,
|
||||
"description": "Publication fee charged per event in msats (1000 msats = 1 satoshi)",
|
||||
"amount": 10,
|
||||
"whitelists": {
|
||||
"pubkeys": ["replace-with-your-pubkey"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"paymentProcessors": {
|
||||
"zebedee": {
|
||||
"baseURL": "https://api.zebedee.io/",
|
||||
"callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"maxPayloadSize": 131072,
|
||||
"remoteIpHeader": "x-forwarded-for",
|
||||
"idleTimeout": 60
|
||||
},
|
||||
"workers": {
|
||||
"count": 0
|
||||
},
|
||||
"limits": {
|
||||
"invoice": {
|
||||
"rateLimits": [
|
||||
{
|
||||
"period": 60000,
|
||||
"rate": 1
|
||||
},
|
||||
{
|
||||
"period": 3600000,
|
||||
"rate": 30
|
||||
},
|
||||
{
|
||||
"period": 86400000,
|
||||
"rate": 360
|
||||
}
|
||||
],
|
||||
"ipWhitelist": [
|
||||
"::1",
|
||||
"::ffff:10.10.10.1"
|
||||
]
|
||||
},
|
||||
"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
|
||||
},
|
||||
"kind": {
|
||||
"whitelist": [],
|
||||
"blacklist": []
|
||||
},
|
||||
"pubkey": {
|
||||
"minBalanceMsats": 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",
|
||||
"::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",
|
||||
"::ffff:10.10.10.1"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
BIN
resources/favicon.ico
Normal file
BIN
resources/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
135
resources/index.html
Normal file
135
resources/index.html
Normal file
@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pay To Relay - {{name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<style>
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body lang="en" onload="onLoad()">
|
||||
<main class="container">
|
||||
<form method="post" action="/invoices">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<p>
|
||||
This <a href="https://github.com/nostr-protocol/nostr">Nostr</a> relay <strong>requires</strong> a one-time admission fee.
|
||||
</p>
|
||||
<p>
|
||||
Provide your public key to generate an invoice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="card col col-md-10 col-lg-6 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column mb-4">
|
||||
<label for="pubkey" class="h5">Your Nostr public key</label>
|
||||
<input type="text" name="pubkey" class="form-control form-control-sm" id="pubkey" placeholder="npub... or hex..." pattern="^(npub|[0-9a-f]{64})" required>
|
||||
<div id="pubkeyAfterHelpBlock" class="form-text">
|
||||
Hex or npub formats accepted.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="tosAccepted" name="tosAccepted" value="yes" required>
|
||||
<label class="form-check-label" for="tosAccepted">
|
||||
I have read and agree to the <a href="#" class="card-link" data-bs-toggle="modal" data-bs-target="#tosModal">Terms of Service</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<button id="submitBtn" class="btn btn-lg btn-warning" type="submit">Pay {{amount}} sats</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
<div class="modal" id="tosModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-fullscreen-md-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Terms of Service</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
These are the terms of service for {{name}}; please read them before using {{name}}.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
By connecting to this relay, you agree:
|
||||
<ul>
|
||||
<li>To not engage in spam</li>
|
||||
<li>To not flood</li>
|
||||
<li>To not expect content moderation</li>
|
||||
<li>To not misuse or abuse the relay service and other supporting services</li>
|
||||
<li>To not disseminate illegal content or material</li>
|
||||
<li>That requests to delete content you published cannot be guaranteed</li>
|
||||
<li>That this relay has no control over any content published in other relays</li>
|
||||
<li>That some services, such as but not limited to the privilege to publish content may require payment(s)</li>
|
||||
<li>That charge backs from payments may result in the termination of the privilege to use the service</li>
|
||||
<li>That the service might be revoked to you at the operator's sole discretion if found in violation of these terms</li>
|
||||
<li>That the terms of service may change at any time in the future without explicit notice</li>
|
||||
<li>To grant us the necessary rights to your content to provide the service to you and to other users for an unlimited time</li>
|
||||
<li>To use the service in compliance with all laws, rules, and regulations applicable to you</li>
|
||||
<li>To use the service in good faith and not seek to get the relay operator(s) in trouble</li>
|
||||
<li>That the service may throttle, rate limit or revoke your access to any content and/or your privilege to publish content for any reason</li>
|
||||
<li>That the content you publish to this relay will be further broadcasted to any interested client and/or accepting relay</li>
|
||||
<li>To not infringe on the right of others to publish content to this relay as allowed by the terms of service</li>
|
||||
<li>That this service is not targeted, nor intended for use by, anyone under the legal age in their respective jurisdiction</li>
|
||||
<li>To be of legal age or have sufficient legal constent, permission and capacity to use this service</li>
|
||||
<li>That the service may be temporarily shutdown or permanently terminated at any time and without notice</li>
|
||||
<li>That the content published by you and other users may be removed at any time and without notice and for any reason</li>
|
||||
<li>To have your IP address and/or public key collected for the purpose of detecting abuse, spam or misuse</li>
|
||||
<li>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</li>
|
||||
<li>To cooperate with the relay and its operators for the purpose of combating abuse, spam or misuse of the service</li>
|
||||
</ul>
|
||||
<br/>
|
||||
In addition you understand that:
|
||||
<ul>
|
||||
<li>Nostr is a decentralized and distributed network of relays that relays data by users.</li>
|
||||
<li>Censorship resistance is practiced by posting to multiple relays and running your own private relay.</li>
|
||||
<li>The responsibility of filtering and moderating is the sole responsibility of the users and not of the relays.</li>
|
||||
<li>You may be inadvertently exposed to content that you might find triggering, disturbing, distasteful, immoral or against your views.</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
function onLoad() {
|
||||
const maxRetries = 10
|
||||
const getPubKey = (retries) => {
|
||||
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
|
||||
window.nostr.getPublicKey().then((pubkey) => {
|
||||
console.log(pubkey)
|
||||
document.getElementById('pubkey').setAttribute('value', pubkey)
|
||||
}).catch(console.error.bind(console))
|
||||
} else if (retries > 0) {
|
||||
setTimeout(() => getPubKey(retries - 1), 100)
|
||||
}
|
||||
}
|
||||
getPubKey(maxRetries)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
151
resources/invoices.html
Normal file
151
resources/invoices.html
Normal file
@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pay Invoice - {{name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<script
|
||||
src="https://unpkg.com/webln@0.2.0/dist/webln.min.js"
|
||||
integrity="sha384-mTReBqbhPO7ljQeIoFaD1NYS2KiYMwFJhUNpdwLj+VIuhhjvHQlZ1XpwzAvd93nQ"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
</head>
|
||||
<body lang="en">
|
||||
<main class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<p class="pending">
|
||||
Scan with your bitcoin lightning wallet
|
||||
</p>
|
||||
<p class="paid text-success d-none">
|
||||
Your payment has been received
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="card pending col-8 col-lg-4 d-flex flex-column justify-content-center mb-4">
|
||||
<div class="card-body m-auto">
|
||||
<div id="invoice" onclick="sendPayment()"></div>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-row justify-content-center">
|
||||
<div class="input-group input-group-sm w-100 mw-256" onclick="copy()">
|
||||
<input type="text" name="invoice" class="form-control form-control-sm" id="invoiceInput" value="{{invoice}}" readonly>
|
||||
<span class="input-group-text" id="invoiceAlert">copy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-row justify-content-center">
|
||||
<div id="waiting">
|
||||
<div class="spinner-grow spinner-grow-sm" role="status"></div>
|
||||
Waiting for payment
|
||||
</div>
|
||||
<div id="paid" class="hidden">
|
||||
✅ Payment received
|
||||
</div>
|
||||
<div id="timeout" class="hidden">
|
||||
❌ Invoice expired
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card paid d-none col-8 col-lg-4 justify-content-center">
|
||||
<div class="card-body text-center">
|
||||
<div class="success-checkmark">
|
||||
<div class="check-icon">
|
||||
<span class="icon-line line-tip"></span>
|
||||
<span class="icon-line line-long"></span>
|
||||
<div class="icon-circle"></div>
|
||||
<div class="icon-fix"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-success">Payment successful</h2>
|
||||
<p class="text-secondary">{{amount}} sats received</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card expired col-8 col-lg-4 justify-content-center">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="text-warning">Invoice expired</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pending">
|
||||
<div class="col">
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<button id="sendPaymentBtn" class="btn btn-lg btn-warning d-none" type="submit" onclick="sendPayment()">Pay with wallet</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script>
|
||||
var relayUrl = "{{relay_url}}"
|
||||
var relayPubkey = "{{relay_pubkey}}"
|
||||
var invoice = "{{invoice}}";
|
||||
var pubkey = "{{pubkey}}"
|
||||
|
||||
function connect() {
|
||||
var socket = new WebSocket(relayUrl)
|
||||
socket.onopen = () => {
|
||||
console.log('connected')
|
||||
socket.send(JSON.stringify(['REQ', 'payment', { kinds: [402], authors: [relayPubkey] }]))
|
||||
}
|
||||
|
||||
socket.onmessage = (raw) => {
|
||||
const message = JSON.parse(raw.data)
|
||||
console.log('received', message)
|
||||
|
||||
if (!Array.isArray(message) || message.length !== 3 || message[0] !== 'EVENT' || message[1] !== 'payment') {
|
||||
return
|
||||
}
|
||||
|
||||
// hide waiting
|
||||
const pendingElements = document.getElementsByClassName('pending')
|
||||
const paidElements = document.getElementsByClassName('paid')
|
||||
for (const elem of pendingElements) {
|
||||
elem.classList.add('d-none')
|
||||
}
|
||||
for (const elem of paidElements) {
|
||||
elem.classList.remove('d-none')
|
||||
}
|
||||
}
|
||||
|
||||
socket.onerror = console.error.bind(console)
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('disconnected')
|
||||
setTimeout(connect, 0)
|
||||
}
|
||||
}
|
||||
|
||||
new QRCode(document.getElementById("invoice"), {
|
||||
text: `lightning:${invoice}`,
|
||||
width: 256,
|
||||
height: 256,
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
|
||||
function copy() {
|
||||
var elem = document.getElementById('invoiceInput')
|
||||
elem.select()
|
||||
elem.setSelectionRange(0, 999999)
|
||||
navigator.clipboard.writeText(elem.value)
|
||||
document.getElementById('invoiceAlert').innerText = 'copied!'
|
||||
}
|
||||
async function sendPayment() {
|
||||
const webln = await WebLN.requestProvider();
|
||||
//webln.sendPayment(invoice)
|
||||
webln.sendPayment('lnbc10u1p3menvwpp5gtjz9n8vvwpeeav7884rzx2g0mlgcxy8rjkcxwmxscxcqmfg690qdpuxycrqvpqwdshgueqvehhygzqvdsk6etjdysx7m3qwd6xzcmtv4ezumn9waescqzpgxqr230sp5y3rj64jfchnhvequdreh5yrh8yr7lle9s3mr85wvhlqgmvdnd2xq9qyyssqc8spqduevg3s04j3975mt8jp3frsma87uqa85tra50dhf9xc5rwkwu6lh677t5zz9laeg20pgyedg24xck7lfjygyftslaax2ht9jqcp62y039')
|
||||
}
|
||||
connect()
|
||||
sendPayment().catch(() => {
|
||||
document.getElementById('sendPaymentBtn').classList.remove('d-none')
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
66
resources/terms.html
Normal file
66
resources/terms.html
Normal file
@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Terms of Service Agreement - {{name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
</head>
|
||||
<body lang="en">
|
||||
<main class="form-request-invoice m-auto">
|
||||
<h1 class="h1 mb-3">{{name}}</h1>
|
||||
<div class="card" style="width: 38rem;">
|
||||
<div class="card-body">
|
||||
<h3>Terms of Service Agreement</h3>
|
||||
<p>
|
||||
These are the terms of service for {{name}}; please read them before using {{name}}.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
By connecting to this relay, you agree:
|
||||
<ul>
|
||||
<li>To not engage in spam</li>
|
||||
<li>To not flood</li>
|
||||
<li>To not expect content moderation</li>
|
||||
<li>To not misuse or abuse the relay service and other supporting services</li>
|
||||
<li>To not disseminate illegal content or material</li>
|
||||
<li>That requests to delete content you published cannot be guaranteed</li>
|
||||
<li>That this relay has no control over any content published in other relays</li>
|
||||
<li>That some services, such as but not limited to the privilege to publish content may require payment(s)</li>
|
||||
<li>That charge backs from payments may result in the termination of the privilege to use the service</li>
|
||||
<li>That the service might be revoked to you at the operator's sole discretion if found in violation of these terms</li>
|
||||
<li>That the terms of service may change at any time in the future without explicit notice</li>
|
||||
<li>To grant us the necessary rights to your content to provide the service to you and to other users for an unlimited time</li>
|
||||
<li>To use the service in compliance with all laws, rules, and regulations applicable to you</li>
|
||||
<li>To use the service in good faith and not seek to get the relay operator(s) in trouble</li>
|
||||
<li>That the service may throttle, rate limit or revoke your access to any content and/or your privilege to publish content for any reason</li>
|
||||
<li>That the content you publish to this relay will be further broadcasted to any interested client and/or accepting relay</li>
|
||||
<li>To not infringe on the right of others to publish content to this relay as allowed by the terms of service</li>
|
||||
<li>That this service is not targeted, nor intended for use by, anyone under the legal age in their respective jurisdiction</li>
|
||||
<li>To be of legal age or have sufficient legal constent, permission and capacity to use this service</li>
|
||||
<li>That the service may be temporarily shutdown or permanently terminated at any time and without notice</li>
|
||||
<li>That the content published by you and other users may be removed at any time and without notice and for any reason</li>
|
||||
<li>To have your IP address and/or public key collected for the purpose of detecting abuse, spam or misuse</li>
|
||||
<li>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</li>
|
||||
<li>To cooperate with the relay and its operators for the purpose of combating abuse, spam or misuse of the service</li>
|
||||
</ul>
|
||||
<br/>
|
||||
In addition you understand that:
|
||||
<ul>
|
||||
<li>Nostr is a decentralized and distributed network of relays that relays data by users.</li>
|
||||
<li>Censorship resistance is practiced by posting to multiple relays and running your own private relay.</li>
|
||||
<li>The responsibility of filtering and moderating is the sole responsibility of the users and not of the relays.</li>
|
||||
<li>You may be inadvertently exposed to content that you might find triggering, disturbing, distasteful, immoral or against your views.</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
@ -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 $@
|
||||
|
13
scripts/update
Executable file
13
scripts/update
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.."
|
||||
SCRIPTS_DIR="${PROJECT_ROOT}/scripts"
|
||||
|
||||
git stash -u
|
||||
|
||||
git pull
|
||||
|
||||
git stash pop
|
||||
|
||||
$SCRIPTS_DIR/stop
|
||||
|
||||
$SCRIPTS_DIR/start
|
@ -7,13 +7,34 @@
|
||||
"contact": "operator@your-domain.com"
|
||||
},
|
||||
"network": {
|
||||
"max_payload_size": 131072,
|
||||
"remote_ip_header": "x-forwarded-for"
|
||||
"maxPayloadSize": 131072,
|
||||
"remoteIpHeader": "x-forwarded-for",
|
||||
"idleTimeout": 60
|
||||
},
|
||||
"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
|
||||
@ -23,6 +44,7 @@
|
||||
"blacklist": []
|
||||
},
|
||||
"pubkey": {
|
||||
"minBalanceMsats": 10000,
|
||||
"minLeadingZeroBits": 0,
|
||||
"whitelist": [],
|
||||
"blacklist": []
|
||||
@ -109,4 +131,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
19
src/@types/clients.ts
Normal file
19
src/@types/clients.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export interface InvoiceEnvelope {
|
||||
bolt11: string
|
||||
}
|
||||
|
||||
export interface CreateInvoiceResponse {
|
||||
externalReference: string
|
||||
amount: number
|
||||
invoice: InvoiceEnvelope
|
||||
}
|
||||
|
||||
export interface CreateInvoiceRequest {
|
||||
amountMsats: number
|
||||
description?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface IPaymentsProcessor {
|
||||
createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse>
|
||||
}
|
5
src/@types/controllers.ts
Normal file
5
src/@types/controllers.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
export interface IController {
|
||||
handleRequest(request: Request, response: Response): Promise<void>
|
||||
}
|
@ -12,6 +12,10 @@ export interface Event {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type UnsignedEvent = Omit<Event, 'sig'>
|
||||
|
||||
export type UnidentifiedEvent = Omit<UnsignedEvent, 'id'>
|
||||
|
||||
export interface DelegatedEvent extends Event {
|
||||
[EventDelegatorMetadataKey]?: Pubkey
|
||||
}
|
||||
|
42
src/@types/invoice.ts
Normal file
42
src/@types/invoice.ts
Normal file
@ -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
|
||||
expiresAt: Date
|
||||
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
|
||||
}
|
@ -2,6 +2,7 @@ import { PassThrough } from 'stream'
|
||||
|
||||
import { DBEvent, Event } from './event'
|
||||
import { EventId, Pubkey } from './base'
|
||||
import { Invoice } from './invoice'
|
||||
import { SubscriptionFilter } from './subscription'
|
||||
|
||||
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'
|
||||
@ -17,3 +18,8 @@ export interface IEventRepository {
|
||||
insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise<number>
|
||||
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
|
||||
}
|
||||
|
||||
export interface IInvoiceRepository {
|
||||
findById(id: string): Promise<Invoice | undefined>
|
||||
upsert(invoice: Invoice): Promise<number>
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { EventKinds } from '../constants/base'
|
||||
import { MessageType } from './messages'
|
||||
import { Pubkey } from './base'
|
||||
|
||||
export interface Info {
|
||||
@ -10,8 +11,14 @@ export interface Info {
|
||||
}
|
||||
|
||||
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 +26,7 @@ export interface EventIdLimits {
|
||||
}
|
||||
|
||||
export interface PubkeyLimits {
|
||||
minBalanceMsats: number
|
||||
minLeadingZeroBits: number
|
||||
whitelist?: Pubkey[]
|
||||
blacklist?: Pubkey[]
|
||||
@ -26,10 +34,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 +55,11 @@ export interface CreatedAtLimits {
|
||||
}
|
||||
|
||||
export interface ContentLimits {
|
||||
description?: string
|
||||
kinds?: (EventKinds | EventKindsRange)[]
|
||||
/**
|
||||
* Maximum number of characters allowed on events
|
||||
*/
|
||||
maxLength?: number
|
||||
}
|
||||
|
||||
@ -62,7 +73,7 @@ export interface EventLimits {
|
||||
pubkey?: PubkeyLimits
|
||||
kind?: KindLimits
|
||||
createdAt?: CreatedAtLimits
|
||||
content?: ContentLimits
|
||||
content?: ContentLimits | ContentLimits[]
|
||||
rateLimits?: EventRateLimit[]
|
||||
whitelists?: EventWhitelists
|
||||
}
|
||||
@ -76,9 +87,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 +96,19 @@ export interface MessageLimits {
|
||||
ipWhitelist?: string[]
|
||||
}
|
||||
|
||||
export interface ConnectionLimits {
|
||||
rateLimits: RateLimit[]
|
||||
ipWhitelist?: string[]
|
||||
}
|
||||
|
||||
export interface InvoiceLimits {
|
||||
rateLimits: RateLimit[]
|
||||
ipWhitelist?: string[]
|
||||
}
|
||||
|
||||
export interface Limits {
|
||||
invoice?: InvoiceLimits
|
||||
connection?: ConnectionLimits
|
||||
client?: ClientLimits
|
||||
event?: EventLimits
|
||||
message?: MessageLimits
|
||||
@ -96,9 +118,42 @@ export interface Worker {
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface FeeScheduleWhitelists {
|
||||
pubkeys?: Pubkey[]
|
||||
}
|
||||
|
||||
export interface FeeSchedule {
|
||||
enabled: boolean
|
||||
description?: string
|
||||
amount: number
|
||||
whitelists?: FeeScheduleWhitelists
|
||||
}
|
||||
|
||||
export interface FeeSchedules {
|
||||
admission: FeeSchedule[]
|
||||
publication: FeeSchedule[]
|
||||
}
|
||||
|
||||
export interface Payments {
|
||||
enabled: boolean
|
||||
processor: keyof PaymentProcessors
|
||||
feeSchedules: FeeSchedules
|
||||
}
|
||||
|
||||
export interface ZebedeePaymentProcessor {
|
||||
baseURL: string
|
||||
callbackBaseURL: string
|
||||
}
|
||||
|
||||
export interface PaymentProcessors {
|
||||
zebedee?: ZebedeePaymentProcessor
|
||||
}
|
||||
|
||||
export interface ISettings {
|
||||
info: Info
|
||||
network?: Network
|
||||
payments?: Payments
|
||||
paymentProcessors?: PaymentProcessors
|
||||
network: Network
|
||||
workers?: Worker
|
||||
limits?: Limits
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { Duplex, EventEmitter } from 'stream'
|
||||
import { IncomingMessage, Server, ServerResponse } from 'http'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
// import packageJson from '../../package.json'
|
||||
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { Factory } from '../@types/base'
|
||||
import { getRemoteAddress } from '../utils/http'
|
||||
import { IRateLimiter } from '../@types/utils'
|
||||
import { ISettings } from '../@types/settings'
|
||||
import { IWebServerAdapter } from '../@types/adapters'
|
||||
|
||||
@ -12,12 +15,13 @@ const debug = createLogger('web-server-adapter')
|
||||
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
|
||||
public constructor(
|
||||
protected readonly webServer: Server,
|
||||
private readonly slidingWindowRateLimiter: Factory<IRateLimiter>,
|
||||
private readonly settings: () => ISettings,
|
||||
) {
|
||||
debug('web server starting')
|
||||
super()
|
||||
this.webServer
|
||||
.on('request', this.onRequest.bind(this))
|
||||
//.on('request', this.onRequest.bind(this))
|
||||
.on('error', this.onError.bind(this))
|
||||
.on('clientError', this.onClientError.bind(this))
|
||||
.once('close', this.onClose.bind(this))
|
||||
@ -33,31 +37,108 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
|
||||
debug('listening for incoming connections')
|
||||
}
|
||||
|
||||
private onRequest(request: IncomingMessage, response: ServerResponse) {
|
||||
private async 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,
|
||||
}
|
||||
const clientAddress = getRemoteAddress(request, this.settings())
|
||||
|
||||
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.')
|
||||
if (await this.isRateLimited(clientAddress)) {
|
||||
response.end()
|
||||
}
|
||||
|
||||
// const {
|
||||
// info: { name, description, pubkey, contact },
|
||||
// } = this.settings()
|
||||
|
||||
// try {
|
||||
// if (request.method === 'GET' && request.headers['accept'] === 'application/nostr+json') {
|
||||
// const relayInformationDocument = {
|
||||
// name,
|
||||
// description,
|
||||
// pubkey,
|
||||
// contact,
|
||||
// supported_nips: packageJson.supportedNips,
|
||||
// software: packageJson.repository.url,
|
||||
// version: packageJson.version,
|
||||
// }
|
||||
|
||||
// response.setHeader('content-type', 'application/nostr+json')
|
||||
// response.setHeader('access-control-allow-origin', '*')
|
||||
// const body = JSON.stringify(relayInformationDocument)
|
||||
// response.end(body)
|
||||
// } else if (request.headers['upgrade'] !== 'connection') {
|
||||
// const url = new URL(request.url, `https://${request.headers.host}`)
|
||||
// if (request.method === 'GET' && url.pathname === '/') {
|
||||
// response.setHeader('content-type', 'text/html; charset=utf-8')
|
||||
// response.write('<html>')
|
||||
// response.write('<head>')
|
||||
// response.write(`<title>${name}</title>`)
|
||||
// response.write('</head>')
|
||||
// response.write('<body>')
|
||||
// response.write('<form action="/generate-invoice">')
|
||||
// response.write('Public key (HEX): ')
|
||||
// response.write('<input name="pubkey" type="text" value="" minlength="64" maxlength="64" />')
|
||||
// response.write('<input type="submit" value="Request invoice" />')
|
||||
// response.write('</form>')
|
||||
// response.write('</body>')
|
||||
// response.write('</html>')
|
||||
// response.end()
|
||||
// } else if (request.method === 'GET' && url.pathname === '/generate-invoice') {
|
||||
// response.setHeader('content-type', 'text/html; charset=utf-8')
|
||||
// response.write('<html>')
|
||||
// response.write('<head>')
|
||||
// response.write(`<title>${name}</title>`)
|
||||
// response.write('</head>')
|
||||
// response.write('<body>')
|
||||
// response.write('Invoice ')
|
||||
// response.write(JSON.stringify(url.searchParams))
|
||||
// response.write('</body>')
|
||||
// response.write('</html>')
|
||||
// response.end()
|
||||
// } else {
|
||||
// response.setHeader('content-type', 'text/plain')
|
||||
// response.end('Please use a Nostr client to connect.')
|
||||
// }
|
||||
// }
|
||||
// } catch (error) {
|
||||
// debug('error: %o', error)
|
||||
// response.statusCode = 500
|
||||
// response.end('Internal server error')
|
||||
// }
|
||||
}
|
||||
|
||||
private async isRateLimited(client: string): Promise<boolean> {
|
||||
const {
|
||||
rateLimits,
|
||||
ipWhitelist = [],
|
||||
} = this.settings().limits?.connection ?? {}
|
||||
|
||||
if (ipWhitelist.includes(client)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rateLimiter = this.slidingWindowRateLimiter()
|
||||
|
||||
const hit = (period: number, rate: number) =>
|
||||
rateLimiter.hit(
|
||||
`${client}:connection:${period}`,
|
||||
1,
|
||||
{ period: period, rate: rate },
|
||||
)
|
||||
|
||||
let limited = false
|
||||
for (const { rate, period } of rateLimits) {
|
||||
const isRateLimited = await hit(period, rate)
|
||||
|
||||
|
||||
if (isRateLimited) {
|
||||
debug('rate limited %s: %d messages / %d ms exceeded', client, rate, period)
|
||||
|
||||
limited = true
|
||||
}
|
||||
}
|
||||
|
||||
return limited
|
||||
}
|
||||
|
||||
private onError(error: Error) {
|
||||
|
@ -13,6 +13,7 @@ import { attemptValidation } from '../utils/validation'
|
||||
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'
|
||||
@ -42,8 +43,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
||||
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
|
||||
|
||||
this.clientAddress = getRemoteAddress(this.request, this.settings())
|
||||
|
||||
this.client
|
||||
.on('message', this.onClientMessage.bind(this))
|
||||
@ -203,10 +204,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 +215,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() {
|
||||
|
@ -6,6 +6,7 @@ import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { Event } from '../@types/event'
|
||||
import { Factory } from '../@types/base'
|
||||
import { IRateLimiter } from '../@types/utils'
|
||||
import { ISettings } from '../@types/settings'
|
||||
import { propEq } from 'ramda'
|
||||
import { WebServerAdapter } from './web-server-adapter'
|
||||
@ -26,9 +27,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
|
||||
IWebSocketAdapter,
|
||||
[WebSocket, IncomingMessage, IWebSocketServerAdapter]
|
||||
>,
|
||||
slidingWindowRateLimiter: Factory<IRateLimiter>,
|
||||
settings: () => ISettings,
|
||||
) {
|
||||
super(webServer, settings)
|
||||
super(webServer, slidingWindowRateLimiter, settings)
|
||||
|
||||
this.webSocketsAdapters = new WeakMap()
|
||||
|
||||
|
@ -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,9 @@ export enum EventTags {
|
||||
Deduplication = 'd',
|
||||
}
|
||||
|
||||
export enum PaymentsProcessors {
|
||||
ZEBEDEE = 'zebedee',
|
||||
}
|
||||
|
||||
export const EventDelegatorMetadataKey = Symbol('Delegator')
|
||||
export const EventDeduplicationMetadataKey = Symbol('Deduplication')
|
||||
|
95
src/controllers/callbacks/zebedee-callback-controller.ts
Normal file
95
src/controllers/callbacks/zebedee-callback-controller.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { andThen, pipe } from 'ramda'
|
||||
import { Request, Response } from 'express'
|
||||
import cluster from 'cluster'
|
||||
|
||||
import { Event, UnidentifiedEvent } from '../../@types/event'
|
||||
import { EventKinds, PaymentsProcessors } from '../../constants/base'
|
||||
import { getPrivateKeyFromSecret, getPublicKey, identifyEvent, signEvent } from '../../utils/event'
|
||||
import { IEventRepository, IInvoiceRepository } from '../../@types/repositories'
|
||||
import { InvoiceStatus, InvoiceUnit } from '../../@types/invoice'
|
||||
import { createLogger } from '../../factories/logger-factory'
|
||||
import { fromZebedeeInvoice } from '../../utils/transform'
|
||||
import { IController } from '../../@types/controllers'
|
||||
import { WebSocketServerAdapterEvent } from '../../constants/adapter'
|
||||
|
||||
const debug = createLogger('zebedee-callback-controller')
|
||||
|
||||
export class ZebedeeCallbackController implements IController {
|
||||
public constructor(
|
||||
private readonly invoiceRepository: IInvoiceRepository,
|
||||
private readonly eventRepository: IEventRepository,
|
||||
) {}
|
||||
|
||||
// TODO: Validate
|
||||
public async handleRequest(
|
||||
request: Request,
|
||||
response: Response,
|
||||
) {
|
||||
debug('request body: %o', request.body)
|
||||
|
||||
const invoice = fromZebedeeInvoice(request.body)
|
||||
|
||||
try {
|
||||
await this.invoiceRepository.upsert(invoice)
|
||||
} catch (error) {
|
||||
console.error('Unable to persist invoice:', invoice.bolt11)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (invoice.status !== InvoiceStatus.COMPLETED) {
|
||||
response
|
||||
.status(200)
|
||||
.send()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Generate deterministic private key for given pubkey
|
||||
const privkey = getPrivateKeyFromSecret(process.env.SECRET)(invoice.pubkey)
|
||||
const pubkey = getPublicKey(privkey)
|
||||
|
||||
const amountPaid = (invoice.unit === InvoiceUnit.MSATS) ? invoice.amountPaid / 1000n : invoice.amountPaid
|
||||
|
||||
const newEvent: UnidentifiedEvent = {
|
||||
pubkey,
|
||||
kind: EventKinds.INVOICE_UPDATE,
|
||||
created_at: Math.floor(invoice.confirmedAt.getTime() / 1000),
|
||||
content: `✅ ${amountPaid.toString()} ${(invoice.unit === InvoiceUnit.BTC) ? 'BTC' : 'sats'} received`,
|
||||
tags: [
|
||||
['p', invoice.pubkey],
|
||||
['status', invoice.status],
|
||||
['payments-processor', PaymentsProcessors.ZEBEDEE],
|
||||
['r', `lightning:${invoice.bolt11}`],
|
||||
],
|
||||
}
|
||||
|
||||
const event: Event = await pipe(
|
||||
identifyEvent,
|
||||
andThen(signEvent(privkey)),
|
||||
)(newEvent)
|
||||
|
||||
try {
|
||||
await this.eventRepository.create(event)
|
||||
} catch (error) {
|
||||
response.status(500).send(`Unable to save event for invoice: ${invoice.bolt11}`)
|
||||
return
|
||||
} finally {
|
||||
this.broadcastEvent(event)
|
||||
}
|
||||
|
||||
response
|
||||
.status(200)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('OK')
|
||||
}
|
||||
|
||||
private broadcastEvent(event: Event) {
|
||||
if (cluster.isWorker) {
|
||||
process.send({
|
||||
eventName: WebSocketServerAdapterEvent.Broadcast,
|
||||
event,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
35
src/factories/payments-processor-factory.ts
Normal file
35
src/factories/payments-processor-factory.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { createSettings } from './settings-factory'
|
||||
import { ISettings } from '../@types/settings'
|
||||
import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor'
|
||||
import { PaymentsProcessor } from '../payments-processors/payments-procesor'
|
||||
import { ZebedeePaymentsProcesor } from '../payments-processors/zebedee-payments-processor'
|
||||
|
||||
const createZebedeePaymentsProcessor = (settings: ISettings) => {
|
||||
const client = axios.create({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'apikey': process.env.ZEBEDEE_API_KEY,
|
||||
},
|
||||
baseURL: settings.paymentProcessors.zebedee.baseURL,
|
||||
maxRedirects: 1,
|
||||
})
|
||||
|
||||
const zpp = new ZebedeePaymentsProcesor(client, createSettings)
|
||||
return new PaymentsProcessor(zpp)
|
||||
}
|
||||
|
||||
export const createPaymentsProcessor = () => {
|
||||
const settings = createSettings()
|
||||
if (!settings.payments.enabled) {
|
||||
throw new Error('Payments disabled')
|
||||
}
|
||||
|
||||
switch (settings.payments.processor) {
|
||||
case 'zebedee':
|
||||
return createZebedeePaymentsProcessor(settings)
|
||||
default:
|
||||
return new NullPaymentsProcessor()
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import express from 'express'
|
||||
import helmet from 'helmet'
|
||||
import http from 'http'
|
||||
import process from 'process'
|
||||
import { WebSocketServer } from 'ws'
|
||||
@ -6,6 +8,8 @@ import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
|
||||
import { AppWorker } from '../app/worker'
|
||||
import { createSettings } from '../factories/settings-factory'
|
||||
import { EventRepository } from '../repositories/event-repository'
|
||||
import router from '../routes'
|
||||
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
|
||||
import { webSocketAdapterFactory } from './websocket-adapter-factory'
|
||||
import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter'
|
||||
|
||||
@ -14,16 +18,47 @@ export const workerFactory = (): AppWorker => {
|
||||
const readReplicaDbClient = getReadReplicaDbClient()
|
||||
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
|
||||
|
||||
const app = express()
|
||||
app
|
||||
.disable('x-powered-by')
|
||||
.use( helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
/**
|
||||
* TODO: Remove 'unsafe-inline'
|
||||
*/
|
||||
'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)
|
||||
|
||||
const settings = createSettings()
|
||||
|
||||
let maxPayloadSize: number | undefined
|
||||
if (settings.network['max_payload_size']) {
|
||||
console.warn(`WARNING: Setting network.max_payload_size is deprecated and will be removed in a future version.
|
||||
Use network.maxPayloadSize instead.`)
|
||||
maxPayloadSize = settings.network['max_payload_size']
|
||||
} else {
|
||||
maxPayloadSize = settings.network.maxPayloadSize
|
||||
}
|
||||
|
||||
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),
|
||||
slidingWindowRateLimiterFactory,
|
||||
createSettings,
|
||||
)
|
||||
|
||||
|
16
src/factories/zebedee-callback-controller-factory.ts
Normal file
16
src/factories/zebedee-callback-controller-factory.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { EventRepository } from '../repositories/event-repository'
|
||||
import { getDbClient } from '../database/client'
|
||||
import { IController } from '../@types/controllers'
|
||||
import { InvoiceRepository } from '../repositories/invoice-repository'
|
||||
import { ZebedeeCallbackController } from '../controllers/callbacks/zebedee-callback-controller'
|
||||
|
||||
export const createZebedeeCallbackController = (): IController => {
|
||||
const dbClient = getDbClient()
|
||||
const eventRepository = new EventRepository(dbClient)
|
||||
const invoiceRepotistory = new InvoiceRepository(dbClient)
|
||||
|
||||
return new ZebedeeCallbackController(
|
||||
invoiceRepotistory,
|
||||
eventRepository,
|
||||
)
|
||||
}
|
@ -1,10 +1,9 @@
|
||||
import { EventKindsRange, EventRateLimit, ISettings } from '../@types/settings'
|
||||
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventSignatureValid } from '../utils/event'
|
||||
import { EventRateLimit, ISettings } from '../@types/settings'
|
||||
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid } from '../utils/event'
|
||||
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
|
||||
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'
|
||||
@ -61,27 +60,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 +124,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 +170,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 +203,22 @@ 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
|
||||
}
|
||||
|
||||
debug('rate limit check %s: %o', event.pubkey, hits)
|
||||
const isRateLimited = await hit({ period, rate, kinds })
|
||||
|
||||
return hits.some((active) => active)
|
||||
if (isRateLimited) {
|
||||
debug('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period)
|
||||
|
||||
limited = true
|
||||
}
|
||||
}
|
||||
|
||||
return limited
|
||||
}
|
||||
}
|
||||
|
17
src/handlers/request-handlers/get-invoice-request-handler.ts
Normal file
17
src/handlers/request-handlers/get-invoice-request-handler.ts
Normal file
@ -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 getInvoiceRequestHandler = (_req: Request, res: Response, next: NextFunction) => {
|
||||
const { info: { name } } = settings()
|
||||
|
||||
if (!pageCache) {
|
||||
pageCache = readFileSync('./resources/index.html', 'utf8').replaceAll('{{name}}', name)
|
||||
}
|
||||
|
||||
res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache)
|
||||
next()
|
||||
}
|
17
src/handlers/request-handlers/get-terms-request-handler.ts
Normal file
17
src/handlers/request-handlers/get-terms-request-handler.ts
Normal file
@ -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()
|
||||
}
|
147
src/handlers/request-handlers/post-invoice-request-handler.ts
Normal file
147
src/handlers/request-handlers/post-invoice-request-handler.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { getPrivateKeyFromSecret, getPublicKey } from '../../utils/event'
|
||||
import { createLogger } from '../../factories/logger-factory'
|
||||
import { fromNpub } from '../../utils/transform'
|
||||
import { getRemoteAddress } from '../../utils/http'
|
||||
import { IPaymentsProcessor } from '../../@types/clients'
|
||||
import { createSettings as settings } from '../../factories/settings-factory'
|
||||
import { slidingWindowRateLimiterFactory } from '../../factories/rate-limiter-factory'
|
||||
|
||||
let pageCache: string
|
||||
|
||||
const debug = createLogger('post-invoice-request-handler')
|
||||
|
||||
// deepcode ignore NoRateLimitingForExpensiveWebOperation: only read once
|
||||
export const postInvoiceRequestHandler = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (!pageCache) {
|
||||
pageCache = readFileSync('./resources/invoices.html', 'utf8')
|
||||
}
|
||||
|
||||
debug('params: %o', req.params)
|
||||
debug('body: %o', req.body)
|
||||
|
||||
const currentSettings = settings()
|
||||
|
||||
const {
|
||||
info: { name, relay_url },
|
||||
limits: { invoice: { ipWhitelist, rateLimits } },
|
||||
} = currentSettings
|
||||
|
||||
const remoteAddress = getRemoteAddress(req, currentSettings)
|
||||
|
||||
const isRateLimited = async (remoteAddress: string) => {
|
||||
let limited = false
|
||||
if (!ipWhitelist.includes(remoteAddress)) {
|
||||
const rateLimiter = slidingWindowRateLimiterFactory()
|
||||
for (const { rate, period } of rateLimits) {
|
||||
if (await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })) {
|
||||
debug('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
|
||||
limited = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return limited
|
||||
}
|
||||
|
||||
const limited = await isRateLimited(remoteAddress)
|
||||
if (limited) {
|
||||
res
|
||||
.status(429)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('Too many requests')
|
||||
return next()
|
||||
}
|
||||
|
||||
const tosAccepted = req.body?.tosAccepted === 'yes'
|
||||
|
||||
if (!tosAccepted) {
|
||||
res
|
||||
.status(400)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('ToS agreement: not accepted')
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
const pubkeyRaw = typeof req.body?.pubkey === 'string'
|
||||
? req.body?.pubkey?.trim()
|
||||
: undefined
|
||||
|
||||
let pubkey: string
|
||||
if (typeof pubkeyRaw !== 'string') {
|
||||
res
|
||||
.status(400)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('Invalid pubkey: missing')
|
||||
|
||||
return next()
|
||||
} else if (/^[0-9a-f]{64}$/.test(pubkeyRaw)) {
|
||||
pubkey = pubkeyRaw
|
||||
} else if (/^npub/.test(pubkeyRaw)) {
|
||||
try {
|
||||
pubkey = fromNpub(pubkeyRaw)
|
||||
} catch (error) {
|
||||
res
|
||||
.status(400)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('Invalid pubkey: npub not valid')
|
||||
|
||||
return next()
|
||||
}
|
||||
} else {
|
||||
res
|
||||
.status(400)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('Invalid pubkey: unknown format')
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
const admissionFee = currentSettings.payments?.feeSchedules.admission
|
||||
.filter((feeSchedule) => feeSchedule.enabled && !feeSchedule.whitelists.pubkeys.includes(pubkey))
|
||||
|
||||
if (!Array.isArray(admissionFee) || !admissionFee.length) {
|
||||
res
|
||||
.status(400)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('No admission fee required')
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
const paymentsProcessor = req['paymentsProcessor'] as IPaymentsProcessor
|
||||
|
||||
const invoiceResponse = await paymentsProcessor.createInvoice({
|
||||
amountMsats: admissionFee.reduce((sum, fee) => sum + fee.amount, 0),
|
||||
description: `Admission Fee for ${pubkey}`,
|
||||
requestId: pubkey,
|
||||
})
|
||||
|
||||
const privkey = getPrivateKeyFromSecret(process.env.SECRET)(pubkey)
|
||||
const relayPubkey = getPublicKey(privkey)
|
||||
|
||||
const replacements = {
|
||||
name,
|
||||
pubkey,
|
||||
relay_url,
|
||||
relay_pubkey: relayPubkey,
|
||||
invoice: invoiceResponse.invoice.bolt11,
|
||||
}
|
||||
|
||||
const body = Object
|
||||
.entries(replacements)
|
||||
.reduce((body, [key, value]) => body.replaceAll(`{{${key}}}`, value), pageCache)
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.setHeader('Content-Type', 'text/html; charset=utf8')
|
||||
.send(body)
|
||||
|
||||
return next()
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { createZebedeeCallbackController } from '../../factories/zebedee-callback-controller-factory'
|
||||
|
||||
export const postZebedeeCallbackRequestHandler = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const controller = createZebedeeCallbackController()
|
||||
|
||||
return controller.handleRequest(req, res)
|
||||
}
|
6
src/handlers/request-handlers/root-request-handler.ts
Normal file
6
src/handlers/request-handlers/root-request-handler.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
export const rootRequestHandler = (_req: Request, res: Response, next: NextFunction) => {
|
||||
res.redirect(301, '/invoices')
|
||||
next()
|
||||
}
|
13
src/payments-processors/null-payments-processor.ts
Normal file
13
src/payments-processors/null-payments-processor.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
|
||||
|
||||
export class NullPaymentsProcessor implements IPaymentsProcessor {
|
||||
public async createInvoice(_request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
|
||||
return {
|
||||
amount: 0,
|
||||
externalReference: '',
|
||||
invoice: {
|
||||
bolt11: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
11
src/payments-processors/payments-procesor.ts
Normal file
11
src/payments-processors/payments-procesor.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
|
||||
|
||||
export class PaymentsProcessor implements IPaymentsProcessor {
|
||||
public constructor(
|
||||
private readonly processor: IPaymentsProcessor
|
||||
) {}
|
||||
|
||||
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
|
||||
return this.processor.createInvoice(request)
|
||||
}
|
||||
}
|
59
src/payments-processors/zebedee-payments-processor.ts
Normal file
59
src/payments-processors/zebedee-payments-processor.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { applySpec, path, pipe } from 'ramda'
|
||||
import { AxiosInstance } from 'axios'
|
||||
import { Factory } from '../@types/base'
|
||||
|
||||
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { ISettings } from '../@types/settings'
|
||||
import { toJSON } from '../utils/transform'
|
||||
|
||||
const debug = createLogger('zebedee-payments-processor')
|
||||
|
||||
export class ZebedeePaymentsProcesor implements IPaymentsProcessor {
|
||||
public constructor(
|
||||
private httpClient: AxiosInstance,
|
||||
private settings: Factory<ISettings>
|
||||
) {}
|
||||
|
||||
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
|
||||
debug('create invoice: %o', request)
|
||||
const {
|
||||
amountMsats,
|
||||
description,
|
||||
requestId,
|
||||
} = request
|
||||
|
||||
const body = {
|
||||
amount: amountMsats.toString(),
|
||||
description,
|
||||
internalId: requestId,
|
||||
callbackUrl: this.settings().paymentProcessors?.zebedee?.callbackBaseURL,
|
||||
}
|
||||
|
||||
try {
|
||||
debug('request body: %o', body)
|
||||
const response = await this.httpClient.post('/v0/charges', body, {
|
||||
maxRedirects: 1,
|
||||
})
|
||||
|
||||
const result = pipe(
|
||||
applySpec<CreateInvoiceResponse>({
|
||||
externalReference: path(['data', 'id']),
|
||||
amount: pipe(path(['data', 'amount']), Number),
|
||||
invoice: applySpec({
|
||||
bolt11: path(['data', 'invoice', 'request']),
|
||||
}),
|
||||
rawResponse: toJSON,
|
||||
})
|
||||
)(response.data)
|
||||
|
||||
debug('result: %o', result)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Unable to request invoice. Reason:', error.message)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
70
src/repositories/invoice-repository.ts
Normal file
70
src/repositories/invoice-repository.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {
|
||||
applySpec,
|
||||
is,
|
||||
omit,
|
||||
pipe,
|
||||
prop,
|
||||
propSatisfies,
|
||||
toString,
|
||||
when,
|
||||
} from 'ramda'
|
||||
|
||||
import { DBInvoice, Invoice } 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'
|
||||
|
||||
const debug = createLogger('invoice-repository')
|
||||
|
||||
export class InvoiceRepository implements IInvoiceRepository {
|
||||
public constructor(private readonly dbClient: DatabaseClient) { }
|
||||
|
||||
public async findById(id: string): Promise<Invoice> {
|
||||
const [dbInvoice] = await this.dbClient<DBInvoice>('invoices').where('id', id).select()
|
||||
|
||||
if (!dbInvoice) {
|
||||
return
|
||||
}
|
||||
|
||||
return fromDBInvoice(dbInvoice)
|
||||
}
|
||||
|
||||
public upsert(invoice: Invoice): Promise<number> {
|
||||
debug('upserting invoice: %o', invoice)
|
||||
|
||||
const row = applySpec({
|
||||
id: when(propSatisfies(is(String), 'id'), prop('id')),
|
||||
pubkey: pipe(prop('pubkey'), toBuffer),
|
||||
amount_requested: pipe(prop('amountRequested'), toString),
|
||||
amount_paid: when(propSatisfies(is(BigInt), 'amountPaid'), pipe(prop('amountPaid'), toString)),
|
||||
unit: prop('unit'),
|
||||
status: prop('status'),
|
||||
description: prop('description'),
|
||||
confirmed_at: prop('confirmedAt'),
|
||||
expires_at: prop('expiresAt'),
|
||||
})(invoice)
|
||||
|
||||
const query = this.dbClient('invoices')
|
||||
.insert(row)
|
||||
.onConflict('id')
|
||||
.merge(
|
||||
omit([
|
||||
'id',
|
||||
'pubkey',
|
||||
'bolt11',
|
||||
'amount_requested',
|
||||
'unit',
|
||||
'description',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
])(row)
|
||||
)
|
||||
|
||||
return {
|
||||
then: <T1, T2>(onfulfilled: (value: number) => T1 | PromiseLike<T1>, onrejected: (reason: any) => T2 | PromiseLike<T2>) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected),
|
||||
catch: <T>(onrejected: (reason: any) => T | PromiseLike<T>) => query.catch(onrejected),
|
||||
toString: (): string => query.toString(),
|
||||
} as Promise<number>
|
||||
}
|
||||
}
|
10
src/routes/callbacks/index.ts
Normal file
10
src/routes/callbacks/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { json, Router } from 'express'
|
||||
|
||||
import { postZebedeeCallbackRequestHandler } from '../../handlers/request-handlers/post-zebedee-callback-request-handler'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/zebedee', json(), postZebedeeCallbackRequestHandler)
|
||||
|
||||
export default router
|
||||
|
17
src/routes/index.ts
Normal file
17
src/routes/index.ts
Normal file
@ -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
|
11
src/routes/invoices/index.ts
Normal file
11
src/routes/invoices/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Router, urlencoded } from 'express'
|
||||
|
||||
import { getInvoiceRequestHandler } from '../../handlers/request-handlers/get-invoice-request-handler'
|
||||
import { postInvoiceRequestHandler } from '../../handlers/request-handlers/post-invoice-request-handler'
|
||||
|
||||
const invoiceRouter = Router()
|
||||
|
||||
invoiceRouter.get('/', getInvoiceRequestHandler)
|
||||
invoiceRouter.post('/', urlencoded({ extended: true }), postInvoiceRequestHandler)
|
||||
|
||||
export default invoiceRouter
|
9
src/schemas/http-request-schemas.ts
Normal file
9
src/schemas/http-request-schemas.ts
Normal file
@ -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)
|
@ -1,16 +1,18 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
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'
|
||||
|
||||
export const serializeEvent = (event: Event): CanonicalEvent => [
|
||||
export const serializeEvent = (event: UnidentifiedEvent): CanonicalEvent => [
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
@ -29,6 +31,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 +157,42 @@ export const isDelegatedEventValid = async (event: Event): Promise<boolean> => {
|
||||
return secp256k1.schnorr.verify(delegation[3], token, delegation[1])
|
||||
}
|
||||
|
||||
export const isEventIdValid = async (event: Event): Promise<boolean> => {
|
||||
export const getEventHash = async (event: Event | UnidentifiedEvent | UnsignedEvent): Promise<string> => {
|
||||
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<boolean> => {
|
||||
return event.id === await getEventHash(event)
|
||||
}
|
||||
|
||||
export const isEventSignatureValid = async (event: Event): Promise<boolean> => {
|
||||
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
|
||||
}
|
||||
|
||||
export const identifyEvent = async (event: UnidentifiedEvent): Promise<UnsignedEvent> => {
|
||||
const id = await getEventHash(event)
|
||||
|
||||
return { ...event, id }
|
||||
}
|
||||
|
||||
export const getPrivateKeyFromSecret =
|
||||
(secret: string) => (publicKey: Pubkey | Buffer): string => {
|
||||
const hmac = createHmac('sha256', secret)
|
||||
hmac.update(typeof publicKey === 'string' ? Buffer.from(publicKey, 'hex') : publicKey)
|
||||
return hmac.digest().toString('hex')
|
||||
}
|
||||
|
||||
export const getPublicKey = (privkey: string | Buffer) => Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2)
|
||||
|
||||
export const signEvent = (privkey: string | Buffer | undefined) => async (event: UnsignedEvent): Promise<Event> => {
|
||||
const sig = await secp256k1.schnorr.sign(event.id, privkey)
|
||||
return { ...event, sig: Buffer.from(sig).toString('hex') }
|
||||
}
|
||||
|
||||
export const isReplaceableEvent = (event: Event): boolean => {
|
||||
return event.kind === EventKinds.SET_METADATA
|
||||
|| event.kind === EventKinds.CONTACT_LIST
|
||||
|
17
src/utils/http.ts
Normal file
17
src/utils/http.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IncomingMessage } from 'http'
|
||||
|
||||
import { ISettings } from '../@types/settings'
|
||||
|
||||
export const getRemoteAddress = (request: IncomingMessage, settings: ISettings): 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
|
||||
}
|
||||
|
||||
return (request.headers[header] ?? request.socket.remoteAddress) as string
|
||||
}
|
@ -4,8 +4,8 @@ import { join } from 'path'
|
||||
import { mergeDeepRight } from 'ramda'
|
||||
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import defaultSettingsJson from '../../resources/default-settings.json'
|
||||
import { ISettings } from '../@types/settings'
|
||||
import settingsSampleJson from '../../settings.sample.json'
|
||||
|
||||
const debug = createLogger('settings')
|
||||
|
||||
@ -35,7 +35,7 @@ export class SettingsStatic {
|
||||
}
|
||||
debug('creating settings')
|
||||
const path = SettingsStatic.getSettingsFilePath()
|
||||
const defaults = settingsSampleJson as ISettings
|
||||
const defaults = defaultSettingsJson as ISettings
|
||||
try {
|
||||
|
||||
if (fs.existsSync(path)) {
|
||||
|
@ -1,5 +1,67 @@
|
||||
import { applySpec, is, path, pathEq, pipe, prop, propSatisfies, when } from 'ramda'
|
||||
import { bech32 } from 'bech32'
|
||||
|
||||
import { DBInvoice, Invoice } from '../@types/invoice'
|
||||
import { Pubkey } from '../@types/base'
|
||||
|
||||
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 = (input: DBInvoice): Invoice => applySpec<Invoice>({
|
||||
id: prop('id') as () => Pubkey,
|
||||
pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer),
|
||||
bolt11: prop('bolt11'),
|
||||
amountRequested: pipe(prop('amount_requested'), toBigInt),
|
||||
amountPaid: pipe(prop('amount_paid'), 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'),
|
||||
})(input)
|
||||
|
||||
export const fromNpub = (npub: string) => {
|
||||
const { prefix, words } = bech32.decode(npub)
|
||||
if (prefix !== 'npub') {
|
||||
throw new Error('not an npub key')
|
||||
}
|
||||
|
||||
return Buffer.from(
|
||||
bech32.fromWords(words).slice(0, 32)
|
||||
).toString('hex')
|
||||
}
|
||||
|
||||
export const toDate = (input: string) => new Date(input)
|
||||
|
||||
|
||||
export const fromZebedeeInvoice = applySpec<Invoice>({
|
||||
id: prop('id'),
|
||||
pubkey: prop('internalId'),
|
||||
bolt11: path(['invoice', 'request']),
|
||||
amountRequested: pipe(prop('amount'), toBigInt),
|
||||
amountPaid: when(
|
||||
pathEq(['status'], 'completed'),
|
||||
pipe(prop('amount'), toBigInt),
|
||||
),
|
||||
unit: prop('unit'),
|
||||
status: prop('status'),
|
||||
description: prop('description'),
|
||||
confirmedAt: when(
|
||||
propSatisfies(is(String), 'confirmed_at'),
|
||||
pipe(prop('confirmed_at'), toDate),
|
||||
),
|
||||
expiresAt: when(
|
||||
propSatisfies(is(String), 'confirmed_at'),
|
||||
pipe(prop('expires_at'), toDate),
|
||||
),
|
||||
createdAt: pipe(prop('created_at'), toDate),
|
||||
})
|
||||
|
@ -20,7 +20,7 @@ services:
|
||||
DEBUG: ""
|
||||
volumes:
|
||||
- ../../package.json:/code/package.json
|
||||
- ../../settings.sample.json:/code/settings.sample.json
|
||||
- ../../resources/default-settings.json:/code/resources/default-settings.json
|
||||
- ../../src:/code/src
|
||||
- ../../test/integration:/code/test/integration
|
||||
- ../../cucumber.js:/code/cucumber.js
|
||||
|
@ -9,6 +9,7 @@ chai.use(chaiAsPromised)
|
||||
import { EventLimits, ISettings } 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 { IWebSocketAdapter } from '../../../src/@types/adapters'
|
||||
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
|
||||
@ -174,6 +175,7 @@ describe('EventMessageHandler', () => {
|
||||
whitelist: [],
|
||||
},
|
||||
pubkey: {
|
||||
minBalanceMsats: 0,
|
||||
minLeadingZeroBits: 0,
|
||||
blacklist: [],
|
||||
whitelist: [],
|
||||
@ -241,8 +243,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 +252,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 +316,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
|
||||
})
|
||||
})
|
||||
|
||||
@ -666,12 +759,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 +782,7 @@ describe('EventMessageHandler', () => {
|
||||
}
|
||||
)
|
||||
expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly(
|
||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[0]',
|
||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[1]',
|
||||
1,
|
||||
{
|
||||
period: 60000,
|
||||
@ -697,7 +790,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 +822,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 +845,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
|
||||
})
|
||||
})
|
||||
|
68
test/unit/utils/http.spec.ts
Normal file
68
test/unit/utils/http.spec.ts
Normal file
@ -0,0 +1,68 @@
|
||||
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)
|
||||
})
|
||||
|
||||
it('returns undefined if unable to find header', () => {
|
||||
expect(
|
||||
getRemoteAddress(
|
||||
{ ...request, socket: {} } as any,
|
||||
{ network: {} } as any,
|
||||
)
|
||||
).to.be.undefined
|
||||
})
|
||||
|
||||
it('returns undefined if header setting is unset', () => {
|
||||
expect(
|
||||
getRemoteAddress(
|
||||
{ ...request, socket: {} } as any,
|
||||
{} as any,
|
||||
)
|
||||
).to.be.undefined
|
||||
})
|
||||
})
|
@ -6,7 +6,7 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es6",
|
||||
"target": "es2020",
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "Node",
|
||||
"types": ["node", "mocha", "@cucumber/cucumber"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user