mirror of
https://github.com/Cameri/nostream.git
synced 2025-06-11 09:21:14 +02:00
feat: add pay-to-relay
This commit is contained in:
parent
779f7b7fe6
commit
2618a4d2dc
@ -1,19 +1,70 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"relay_url": "wss://nostream.localtest.me",
|
"relay_url": "wss://nostr-relay-dev.wlvs.space",
|
||||||
"name": "nostream.localtest.me",
|
"name": "nostr-relay-dev.wlvs.space",
|
||||||
"description": "A nostr relay written in TypeScript.",
|
"description": "A nostr relay written in Typescript.",
|
||||||
"pubkey": "replace-with-your-pubkey",
|
"pubkey": "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700",
|
||||||
"contact": "operator@your-domain.com"
|
"contact": "operator@your-domain.com"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"max_payload_size": 131072,
|
"maxPayloadSize": 131072,
|
||||||
"remote_ip_header": "x-forwarded-for"
|
"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": {
|
"workers": {
|
||||||
"count": 0
|
"count": 0
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
|
"connection": {
|
||||||
|
"rateLimits": [
|
||||||
|
{
|
||||||
|
"period": 60000,
|
||||||
|
"rate": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"period": 3600000,
|
||||||
|
"rate": 360
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"period": 86400000,
|
||||||
|
"rate": 2880
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ipWhitelist": [
|
||||||
|
"::1",
|
||||||
|
"::ffff:10.10.10.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"event": {
|
"event": {
|
||||||
"eventId": {
|
"eventId": {
|
||||||
"minLeadingZeroBits": 0
|
"minLeadingZeroBits": 0
|
||||||
@ -31,45 +82,62 @@
|
|||||||
"maxPositiveDelta": 900,
|
"maxPositiveDelta": 900,
|
||||||
"maxNegativeDelta": 0
|
"maxNegativeDelta": 0
|
||||||
},
|
},
|
||||||
"content": {
|
"content": [
|
||||||
"maxLength": 1048576
|
{
|
||||||
},
|
"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": [
|
"rateLimits": [
|
||||||
{
|
{
|
||||||
|
"description": "6 events/min for event kinds 0, 3, 40 and 41",
|
||||||
"kinds": [0, 3, 40, 41],
|
"kinds": [0, 3, 40, 41],
|
||||||
"period": 60000,
|
"period": 60000,
|
||||||
"rate": 6
|
"rate": 6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "12 events/min for event kinds 1, 2, 4 and 42",
|
||||||
"kinds": [1, 2, 4, 42],
|
"kinds": [1, 2, 4, 42],
|
||||||
"period": 60000,
|
"period": 60000,
|
||||||
"rate": 12
|
"rate": 12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "360 events/hour for event kinds 1, 2, 4 and 42",
|
||||||
"kinds": [1, 2, 4, 42],
|
"kinds": [1, 2, 4, 42],
|
||||||
"period": 3600000,
|
"period": 3600000,
|
||||||
"rate": 360
|
"rate": 360
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "30 events/min for event kind ranges 5-7 and 43-49",
|
||||||
"kinds": [[5, 7], [43, 49]],
|
"kinds": [[5, 7], [43, 49]],
|
||||||
"period": 60000,
|
"period": 60000,
|
||||||
"rate": 30
|
"rate": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "24 events/min for replaceable events and parameterized replaceable events",
|
||||||
"kinds": [[10000, 19999], [30000, 39999]],
|
"kinds": [[10000, 19999], [30000, 39999]],
|
||||||
"period": 60000,
|
"period": 60000,
|
||||||
"rate": 24
|
"rate": 24
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "60 events/min for ephemeral events",
|
||||||
"kinds": [[20000, 29999]],
|
"kinds": [[20000, 29999]],
|
||||||
"period": 60000,
|
"period": 60000,
|
||||||
"rate": 60
|
"rate": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "720 events/hour for all events",
|
||||||
"period": 3600000,
|
"period": 3600000,
|
||||||
"rate": 720
|
"rate": 720
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "2880 events/day for all events",
|
||||||
"period": 86400000,
|
"period": 86400000,
|
||||||
"rate": 2880
|
"rate": 2880
|
||||||
}
|
}
|
||||||
@ -91,14 +159,29 @@
|
|||||||
"message": {
|
"message": {
|
||||||
"rateLimits": [
|
"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,
|
"period": 60000,
|
||||||
"rate": 120
|
"rate": 120
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "3600 raw messages/hour",
|
||||||
"period": 3600000,
|
"period": 3600000,
|
||||||
"rate": 3600
|
"rate": 3600
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "86400 raw messages/day",
|
||||||
"period": 86400000,
|
"period": 86400000,
|
||||||
"rate": 86400
|
"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.description | Public description of your relay. (e.g. Toronto Bitcoin Group Public Relay) |
|
||||||
| info.pubkey | Relay operator's Nostr pubkey in hex format. |
|
| info.pubkey | Relay operator's Nostr pubkey in hex format. |
|
||||||
| info.contact | Relay operator's contact. (e.g. mailto:operator@relay-your-domain.com) |
|
| 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.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
|
||||||
| network.remote_ip_header | HTTP header from proxy containing IP address from client. |
|
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
|
||||||
| workers.count | Number of workers to spin up to handle incoming connections. |
|
| 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. |
|
| | 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. |
|
| 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.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.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.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[].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[].period | Rate limiting period in milliseconds. |
|
||||||
| limits.event.rateLimits[].rate | Maximum number of events during period. |
|
| limits.event.rateLimits[].rate | Maximum number of events during period. |
|
||||||
|
@ -28,6 +28,8 @@ ENV DB_PASSWORD=nostr-ts-relay
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache --update git
|
RUN apk add --no-cache --update git
|
||||||
|
|
||||||
|
ADD resources /app/resources
|
||||||
|
|
||||||
COPY --from=build /build/dist .
|
COPY --from=build /build/dist .
|
||||||
|
|
||||||
RUN npm install --omit=dev --quiet
|
RUN npm install --omit=dev --quiet
|
||||||
|
@ -3,6 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: nostr-ts-relay
|
container_name: nostr-ts-relay
|
||||||
environment:
|
environment:
|
||||||
|
SECRET: changeme
|
||||||
RELAY_PORT: 8008
|
RELAY_PORT: 8008
|
||||||
NOSTR_CONFIG_DIR: /home/node/
|
NOSTR_CONFIG_DIR: /home/node/
|
||||||
# Master
|
# Master
|
||||||
@ -37,6 +38,8 @@ services:
|
|||||||
# DEBUG: "primary:*"
|
# DEBUG: "primary:*"
|
||||||
# DEBUG: "worker:*"
|
# DEBUG: "worker:*"
|
||||||
# DEBUG: "knex:query"
|
# DEBUG: "knex:query"
|
||||||
|
env_file:
|
||||||
|
- test.env
|
||||||
user: node:node
|
user: node:node
|
||||||
volumes:
|
volumes:
|
||||||
- ${PWD}/.nostr:/home/node/
|
- ${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": "^4.3.1",
|
||||||
"@types/chai-as-promised": "^7.1.5",
|
"@types/chai-as-promised": "^7.1.5",
|
||||||
"@types/debug": "4.1.7",
|
"@types/debug": "4.1.7",
|
||||||
|
"@types/express": "4.17.15",
|
||||||
"@types/mocha": "^9.1.1",
|
"@types/mocha": "^9.1.1",
|
||||||
"@types/node": "^17.0.24",
|
"@types/node": "^17.0.24",
|
||||||
"@types/pg": "^8.6.5",
|
"@types/pg": "^8.6.5",
|
||||||
@ -109,8 +110,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/secp256k1": "1.7.1",
|
"@noble/secp256k1": "1.7.1",
|
||||||
|
"axios": "1.2.2",
|
||||||
|
"bech32": "2.0.0",
|
||||||
|
"body-parser": "1.20.1",
|
||||||
"debug": "4.3.4",
|
"debug": "4.3.4",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
"express": "4.18.2",
|
||||||
|
"helmet": "6.0.1",
|
||||||
"joi": "17.7.0",
|
"joi": "17.7.0",
|
||||||
"knex": "2.4.0",
|
"knex": "2.4.0",
|
||||||
"pg": "8.8.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]}"))/.."
|
PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.."
|
||||||
DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml"
|
DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml"
|
||||||
DOCKER_COMPOSE_TOR_FILE="${PROJECT_ROOT}/docker-compose.tor.yml"
|
DOCKER_COMPOSE_TOR_FILE="${PROJECT_ROOT}/docker-compose.tor.yml"
|
||||||
|
DOCKER_COMPOSE_LOCAL_FILE="${PROJECT_ROOT}/docker-compose.local.yml"
|
||||||
|
|
||||||
docker compose \
|
docker compose \
|
||||||
-f $DOCKER_COMPOSE_FILE \
|
-f $DOCKER_COMPOSE_FILE \
|
||||||
-f $DOCKER_COMPOSE_TOR_FILE \
|
-f $DOCKER_COMPOSE_TOR_FILE \
|
||||||
|
-f $DOCKER_COMPOSE_LOCAL_FILE \
|
||||||
down $@
|
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"
|
"contact": "operator@your-domain.com"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"max_payload_size": 131072,
|
"maxPayloadSize": 131072,
|
||||||
"remote_ip_header": "x-forwarded-for"
|
"remoteIpHeader": "x-forwarded-for",
|
||||||
|
"idleTimeout": 60
|
||||||
},
|
},
|
||||||
"workers": {
|
"workers": {
|
||||||
"count": 0
|
"count": 0
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
|
"connection": {
|
||||||
|
"rateLimits": [
|
||||||
|
{
|
||||||
|
"period": 60000,
|
||||||
|
"rate": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"period": 3600000,
|
||||||
|
"rate": 360
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"period": 86400000,
|
||||||
|
"rate": 2880
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ipWhitelist": [
|
||||||
|
"::1",
|
||||||
|
"::ffff:10.10.10.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"event": {
|
"event": {
|
||||||
"eventId": {
|
"eventId": {
|
||||||
"minLeadingZeroBits": 0
|
"minLeadingZeroBits": 0
|
||||||
@ -23,6 +44,7 @@
|
|||||||
"blacklist": []
|
"blacklist": []
|
||||||
},
|
},
|
||||||
"pubkey": {
|
"pubkey": {
|
||||||
|
"minBalanceMsats": 10000,
|
||||||
"minLeadingZeroBits": 0,
|
"minLeadingZeroBits": 0,
|
||||||
"whitelist": [],
|
"whitelist": [],
|
||||||
"blacklist": []
|
"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
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UnsignedEvent = Omit<Event, 'sig'>
|
||||||
|
|
||||||
|
export type UnidentifiedEvent = Omit<UnsignedEvent, 'id'>
|
||||||
|
|
||||||
export interface DelegatedEvent extends Event {
|
export interface DelegatedEvent extends Event {
|
||||||
[EventDelegatorMetadataKey]?: Pubkey
|
[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 { DBEvent, Event } from './event'
|
||||||
import { EventId, Pubkey } from './base'
|
import { EventId, Pubkey } from './base'
|
||||||
|
import { Invoice } from './invoice'
|
||||||
import { SubscriptionFilter } from './subscription'
|
import { SubscriptionFilter } from './subscription'
|
||||||
|
|
||||||
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'
|
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'
|
||||||
@ -17,3 +18,8 @@ export interface IEventRepository {
|
|||||||
insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise<number>
|
insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise<number>
|
||||||
deleteByPubkeyAndIds(pubkey: Pubkey, ids: 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 { EventKinds } from '../constants/base'
|
||||||
|
import { MessageType } from './messages'
|
||||||
import { Pubkey } from './base'
|
import { Pubkey } from './base'
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
@ -10,8 +11,14 @@ export interface Info {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Network {
|
export interface Network {
|
||||||
max_payload_size?: number
|
maxPayloadSize?: number
|
||||||
remote_ip_header?: string
|
remoteIpHeader?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimit {
|
||||||
|
description?: string
|
||||||
|
period: number
|
||||||
|
rate: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventIdLimits {
|
export interface EventIdLimits {
|
||||||
@ -19,6 +26,7 @@ export interface EventIdLimits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PubkeyLimits {
|
export interface PubkeyLimits {
|
||||||
|
minBalanceMsats: number
|
||||||
minLeadingZeroBits: number
|
minLeadingZeroBits: number
|
||||||
whitelist?: Pubkey[]
|
whitelist?: Pubkey[]
|
||||||
blacklist?: Pubkey[]
|
blacklist?: Pubkey[]
|
||||||
@ -26,10 +34,8 @@ export interface PubkeyLimits {
|
|||||||
|
|
||||||
export type EventKindsRange = [EventKinds, EventKinds]
|
export type EventKindsRange = [EventKinds, EventKinds]
|
||||||
|
|
||||||
export interface EventRateLimit {
|
export interface EventRateLimit extends RateLimit {
|
||||||
kinds?: (EventKinds | [EventKinds, EventKinds])[]
|
kinds?: (EventKinds | [EventKinds, EventKinds])[]
|
||||||
rate: number
|
|
||||||
period: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KindLimits {
|
export interface KindLimits {
|
||||||
@ -49,6 +55,11 @@ export interface CreatedAtLimits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentLimits {
|
export interface ContentLimits {
|
||||||
|
description?: string
|
||||||
|
kinds?: (EventKinds | EventKindsRange)[]
|
||||||
|
/**
|
||||||
|
* Maximum number of characters allowed on events
|
||||||
|
*/
|
||||||
maxLength?: number
|
maxLength?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +73,7 @@ export interface EventLimits {
|
|||||||
pubkey?: PubkeyLimits
|
pubkey?: PubkeyLimits
|
||||||
kind?: KindLimits
|
kind?: KindLimits
|
||||||
createdAt?: CreatedAtLimits
|
createdAt?: CreatedAtLimits
|
||||||
content?: ContentLimits
|
content?: ContentLimits | ContentLimits[]
|
||||||
rateLimits?: EventRateLimit[]
|
rateLimits?: EventRateLimit[]
|
||||||
whitelists?: EventWhitelists
|
whitelists?: EventWhitelists
|
||||||
}
|
}
|
||||||
@ -76,9 +87,8 @@ export interface ClientLimits {
|
|||||||
subscription?: ClientSubscriptionLimits
|
subscription?: ClientSubscriptionLimits
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageRateLimit {
|
export interface MessageRateLimit extends RateLimit {
|
||||||
rate: number
|
type?: MessageType
|
||||||
period: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageLimits {
|
export interface MessageLimits {
|
||||||
@ -86,7 +96,19 @@ export interface MessageLimits {
|
|||||||
ipWhitelist?: string[]
|
ipWhitelist?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConnectionLimits {
|
||||||
|
rateLimits: RateLimit[]
|
||||||
|
ipWhitelist?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceLimits {
|
||||||
|
rateLimits: RateLimit[]
|
||||||
|
ipWhitelist?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Limits {
|
export interface Limits {
|
||||||
|
invoice?: InvoiceLimits
|
||||||
|
connection?: ConnectionLimits
|
||||||
client?: ClientLimits
|
client?: ClientLimits
|
||||||
event?: EventLimits
|
event?: EventLimits
|
||||||
message?: MessageLimits
|
message?: MessageLimits
|
||||||
@ -96,9 +118,42 @@ export interface Worker {
|
|||||||
count: number
|
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 {
|
export interface ISettings {
|
||||||
info: Info
|
info: Info
|
||||||
network?: Network
|
payments?: Payments
|
||||||
|
paymentProcessors?: PaymentProcessors
|
||||||
|
network: Network
|
||||||
workers?: Worker
|
workers?: Worker
|
||||||
limits?: Limits
|
limits?: Limits
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { Duplex, EventEmitter } from 'stream'
|
import { Duplex, EventEmitter } from 'stream'
|
||||||
import { IncomingMessage, Server, ServerResponse } from 'http'
|
import { IncomingMessage, Server, ServerResponse } from 'http'
|
||||||
|
|
||||||
import packageJson from '../../package.json'
|
// import packageJson from '../../package.json'
|
||||||
|
|
||||||
import { createLogger } from '../factories/logger-factory'
|
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 { ISettings } from '../@types/settings'
|
||||||
import { IWebServerAdapter } from '../@types/adapters'
|
import { IWebServerAdapter } from '../@types/adapters'
|
||||||
|
|
||||||
@ -12,12 +15,13 @@ const debug = createLogger('web-server-adapter')
|
|||||||
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
|
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected readonly webServer: Server,
|
protected readonly webServer: Server,
|
||||||
|
private readonly slidingWindowRateLimiter: Factory<IRateLimiter>,
|
||||||
private readonly settings: () => ISettings,
|
private readonly settings: () => ISettings,
|
||||||
) {
|
) {
|
||||||
debug('web server starting')
|
debug('web server starting')
|
||||||
super()
|
super()
|
||||||
this.webServer
|
this.webServer
|
||||||
.on('request', this.onRequest.bind(this))
|
//.on('request', this.onRequest.bind(this))
|
||||||
.on('error', this.onError.bind(this))
|
.on('error', this.onError.bind(this))
|
||||||
.on('clientError', this.onClientError.bind(this))
|
.on('clientError', this.onClientError.bind(this))
|
||||||
.once('close', this.onClose.bind(this))
|
.once('close', this.onClose.bind(this))
|
||||||
@ -33,31 +37,108 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
|
|||||||
debug('listening for incoming connections')
|
debug('listening for incoming connections')
|
||||||
}
|
}
|
||||||
|
|
||||||
private onRequest(request: IncomingMessage, response: ServerResponse) {
|
private async onRequest(request: IncomingMessage, response: ServerResponse) {
|
||||||
debug('request received: %O', request.headers)
|
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 = {
|
const clientAddress = getRemoteAddress(request, this.settings())
|
||||||
name,
|
|
||||||
description,
|
|
||||||
pubkey,
|
|
||||||
contact,
|
|
||||||
supported_nips: packageJson.supportedNips,
|
|
||||||
software: packageJson.repository.url,
|
|
||||||
version: packageJson.version,
|
|
||||||
}
|
|
||||||
|
|
||||||
response.setHeader('content-type', 'application/nostr+json')
|
if (await this.isRateLimited(clientAddress)) {
|
||||||
response.setHeader('access-control-allow-origin', '*')
|
response.end()
|
||||||
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.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
private onError(error: Error) {
|
||||||
|
@ -13,6 +13,7 @@ import { attemptValidation } from '../utils/validation'
|
|||||||
import { createLogger } from '../factories/logger-factory'
|
import { createLogger } from '../factories/logger-factory'
|
||||||
import { Event } from '../@types/event'
|
import { Event } from '../@types/event'
|
||||||
import { Factory } from '../@types/base'
|
import { Factory } from '../@types/base'
|
||||||
|
import { getRemoteAddress } from '../utils/http'
|
||||||
import { IRateLimiter } from '../@types/utils'
|
import { IRateLimiter } from '../@types/utils'
|
||||||
import { ISettings } from '../@types/settings'
|
import { ISettings } from '../@types/settings'
|
||||||
import { isEventMatchingFilter } from '../utils/event'
|
import { isEventMatchingFilter } from '../utils/event'
|
||||||
@ -42,8 +43,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
|||||||
this.subscriptions = new Map()
|
this.subscriptions = new Map()
|
||||||
|
|
||||||
this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex')
|
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
|
this.client
|
||||||
.on('message', this.onClientMessage.bind(this))
|
.on('message', this.onClientMessage.bind(this))
|
||||||
@ -203,10 +204,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
|||||||
rateLimiter.hit(
|
rateLimiter.hit(
|
||||||
`${client}:message:${period}`,
|
`${client}:message:${period}`,
|
||||||
1,
|
1,
|
||||||
{ period: period, rate: rate },
|
{ period, rate },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let limited = false
|
||||||
for (const { rate, period } of rateLimits) {
|
for (const { rate, period } of rateLimits) {
|
||||||
const isRateLimited = await hit(period, rate)
|
const isRateLimited = await hit(period, rate)
|
||||||
|
|
||||||
@ -214,11 +215,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
|||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
debug('rate limited %s: %d messages / %d ms exceeded', client, rate, period)
|
debug('rate limited %s: %d messages / %d ms exceeded', client, rate, period)
|
||||||
|
|
||||||
return true
|
limited = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return limited
|
||||||
}
|
}
|
||||||
|
|
||||||
private onClientPong() {
|
private onClientPong() {
|
||||||
|
@ -6,6 +6,7 @@ import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants
|
|||||||
import { createLogger } from '../factories/logger-factory'
|
import { createLogger } from '../factories/logger-factory'
|
||||||
import { Event } from '../@types/event'
|
import { Event } from '../@types/event'
|
||||||
import { Factory } from '../@types/base'
|
import { Factory } from '../@types/base'
|
||||||
|
import { IRateLimiter } from '../@types/utils'
|
||||||
import { ISettings } from '../@types/settings'
|
import { ISettings } from '../@types/settings'
|
||||||
import { propEq } from 'ramda'
|
import { propEq } from 'ramda'
|
||||||
import { WebServerAdapter } from './web-server-adapter'
|
import { WebServerAdapter } from './web-server-adapter'
|
||||||
@ -26,9 +27,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
|
|||||||
IWebSocketAdapter,
|
IWebSocketAdapter,
|
||||||
[WebSocket, IncomingMessage, IWebSocketServerAdapter]
|
[WebSocket, IncomingMessage, IWebSocketServerAdapter]
|
||||||
>,
|
>,
|
||||||
|
slidingWindowRateLimiter: Factory<IRateLimiter>,
|
||||||
settings: () => ISettings,
|
settings: () => ISettings,
|
||||||
) {
|
) {
|
||||||
super(webServer, settings)
|
super(webServer, slidingWindowRateLimiter, settings)
|
||||||
|
|
||||||
this.webSocketsAdapters = new WeakMap()
|
this.webSocketsAdapters = new WeakMap()
|
||||||
|
|
||||||
|
@ -14,6 +14,9 @@ export enum EventKinds {
|
|||||||
CHANNEL_MUTE_USER = 44,
|
CHANNEL_MUTE_USER = 44,
|
||||||
CHANNEL_RESERVED_FIRST = 45,
|
CHANNEL_RESERVED_FIRST = 45,
|
||||||
CHANNEL_RESERVED_LAST = 49,
|
CHANNEL_RESERVED_LAST = 49,
|
||||||
|
// Relay-only
|
||||||
|
RELAY_INVITE = 50,
|
||||||
|
INVOICE_UPDATE = 402,
|
||||||
// Replaceable events
|
// Replaceable events
|
||||||
REPLACEABLE_FIRST = 10000,
|
REPLACEABLE_FIRST = 10000,
|
||||||
REPLACEABLE_LAST = 19999,
|
REPLACEABLE_LAST = 19999,
|
||||||
@ -35,5 +38,9 @@ export enum EventTags {
|
|||||||
Deduplication = 'd',
|
Deduplication = 'd',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PaymentsProcessors {
|
||||||
|
ZEBEDEE = 'zebedee',
|
||||||
|
}
|
||||||
|
|
||||||
export const EventDelegatorMetadataKey = Symbol('Delegator')
|
export const EventDelegatorMetadataKey = Symbol('Delegator')
|
||||||
export const EventDeduplicationMetadataKey = Symbol('Deduplication')
|
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 http from 'http'
|
||||||
import process from 'process'
|
import process from 'process'
|
||||||
import { WebSocketServer } from 'ws'
|
import { WebSocketServer } from 'ws'
|
||||||
@ -6,6 +8,8 @@ import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
|
|||||||
import { AppWorker } from '../app/worker'
|
import { AppWorker } from '../app/worker'
|
||||||
import { createSettings } from '../factories/settings-factory'
|
import { createSettings } from '../factories/settings-factory'
|
||||||
import { EventRepository } from '../repositories/event-repository'
|
import { EventRepository } from '../repositories/event-repository'
|
||||||
|
import router from '../routes'
|
||||||
|
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
|
||||||
import { webSocketAdapterFactory } from './websocket-adapter-factory'
|
import { webSocketAdapterFactory } from './websocket-adapter-factory'
|
||||||
import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter'
|
import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter'
|
||||||
|
|
||||||
@ -14,16 +18,47 @@ export const workerFactory = (): AppWorker => {
|
|||||||
const readReplicaDbClient = getReadReplicaDbClient()
|
const readReplicaDbClient = getReadReplicaDbClient()
|
||||||
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
|
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
|
// 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({
|
const webSocketServer = new WebSocketServer({
|
||||||
server,
|
server,
|
||||||
maxPayload: createSettings().network?.max_payload_size ?? 131072, // 128 kB
|
maxPayload: maxPayloadSize ?? 131072, // 128 kB
|
||||||
})
|
})
|
||||||
const adapter = new WebSocketServerAdapter(
|
const adapter = new WebSocketServerAdapter(
|
||||||
server,
|
server,
|
||||||
webSocketServer,
|
webSocketServer,
|
||||||
webSocketAdapterFactory(eventRepository),
|
webSocketAdapterFactory(eventRepository),
|
||||||
|
slidingWindowRateLimiterFactory,
|
||||||
createSettings,
|
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 { EventRateLimit, ISettings } from '../@types/settings'
|
||||||
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventSignatureValid } from '../utils/event'
|
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid } from '../utils/event'
|
||||||
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
|
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
|
||||||
import { createCommandResult } from '../utils/messages'
|
import { createCommandResult } from '../utils/messages'
|
||||||
import { createLogger } from '../factories/logger-factory'
|
import { createLogger } from '../factories/logger-factory'
|
||||||
import { Event } from '../@types/event'
|
import { Event } from '../@types/event'
|
||||||
import { EventKinds } from '../constants/base'
|
|
||||||
import { Factory } from '../@types/base'
|
import { Factory } from '../@types/base'
|
||||||
import { IncomingEventMessage } from '../@types/messages'
|
import { IncomingEventMessage } from '../@types/messages'
|
||||||
import { IRateLimiter } from '../@types/utils'
|
import { IRateLimiter } from '../@types/utils'
|
||||||
@ -61,27 +60,63 @@ export class EventMessageHandler implements IMessageHandler {
|
|||||||
|
|
||||||
protected canAcceptEvent(event: Event): string | undefined {
|
protected canAcceptEvent(event: Event): string | undefined {
|
||||||
const now = Math.floor(Date.now()/1000)
|
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`
|
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`
|
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`
|
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)
|
const pow = getEventProofOfWork(event.id)
|
||||||
if (pow < limits.eventId.minLeadingZeroBits) {
|
if (pow < limits.eventId.minLeadingZeroBits) {
|
||||||
return `pow: difficulty ${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)
|
const pow = getPubkeyProofOfWork(event.pubkey)
|
||||||
if (pow < limits.pubkey.minLeadingZeroBits) {
|
if (pow < limits.pubkey.minLeadingZeroBits) {
|
||||||
return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`
|
return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`
|
||||||
@ -89,29 +124,32 @@ export class EventMessageHandler implements IMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
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))
|
&& !limits.pubkey.whitelist.some((prefix) => event.pubkey.startsWith(prefix))
|
||||||
) {
|
) {
|
||||||
return 'blocked: pubkey not allowed'
|
return 'blocked: pubkey not allowed'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
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))
|
&& limits.pubkey.blacklist.some((prefix) => event.pubkey.startsWith(prefix))
|
||||||
) {
|
) {
|
||||||
return 'blocked: pubkey not allowed'
|
return 'blocked: pubkey not allowed'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEventKindMatch = (item: EventKinds | EventKindsRange) =>
|
if (
|
||||||
typeof item === 'number'
|
typeof limits.kind?.whitelist !== 'undefined'
|
||||||
? item === event.kind
|
&& limits.kind.whitelist.length > 0
|
||||||
: event.kind >= item[0] && event.kind <= item[1]
|
&& !limits.kind.whitelist.some(isEventKindOrRangeMatch(event))) {
|
||||||
|
|
||||||
if (limits.kind.whitelist.length > 0 && !limits.kind.whitelist.some(isEventKindMatch)) {
|
|
||||||
return `blocked: event kind ${event.kind} not allowed`
|
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`
|
return `blocked: event kind ${event.kind} not allowed`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -132,13 +170,16 @@ export class EventMessageHandler implements IMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Array.isArray(whitelists?.pubkeys)
|
typeof whitelists?.pubkeys !== 'undefined'
|
||||||
|
&& Array.isArray(whitelists?.pubkeys)
|
||||||
&& whitelists.pubkeys.includes(event.pubkey)
|
&& whitelists.pubkeys.includes(event.pubkey)
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(whitelists?.ipAddresses)
|
if (
|
||||||
|
typeof whitelists?.ipAddresses !== 'undefined'
|
||||||
|
&& Array.isArray(whitelists?.ipAddresses)
|
||||||
&& whitelists.ipAddresses.includes(this.webSocket.getClientAddress())
|
&& whitelists.ipAddresses.includes(this.webSocket.getClientAddress())
|
||||||
) {
|
) {
|
||||||
return false
|
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 * as secp256k1 from '@noble/secp256k1'
|
||||||
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
|
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 { EventId, Pubkey, Tag } from '../@types/base'
|
||||||
import { EventKinds, EventTags } from '../constants/base'
|
import { EventKinds, EventTags } from '../constants/base'
|
||||||
|
import { EventKindsRange } from '../@types/settings'
|
||||||
import { fromBuffer } from './transform'
|
import { fromBuffer } from './transform'
|
||||||
import { getLeadingZeroBits } from './proof-of-work'
|
import { getLeadingZeroBits } from './proof-of-work'
|
||||||
import { isGenericTagQuery } from './filter'
|
import { isGenericTagQuery } from './filter'
|
||||||
import { RuneLike } from './runes/rune-like'
|
import { RuneLike } from './runes/rune-like'
|
||||||
import { SubscriptionFilter } from '../@types/subscription'
|
import { SubscriptionFilter } from '../@types/subscription'
|
||||||
|
|
||||||
export const serializeEvent = (event: Event): CanonicalEvent => [
|
export const serializeEvent = (event: UnidentifiedEvent): CanonicalEvent => [
|
||||||
0,
|
0,
|
||||||
event.pubkey,
|
event.pubkey,
|
||||||
event.created_at,
|
event.created_at,
|
||||||
@ -29,6 +31,12 @@ export const toNostrEvent: (event: DBEvent) => Event = applySpec({
|
|||||||
sig: pipe(prop('event_signature') as () => Buffer, fromBuffer),
|
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 => {
|
export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => {
|
||||||
const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix)
|
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])
|
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))))
|
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> => {
|
export const isEventSignatureValid = async (event: Event): Promise<boolean> => {
|
||||||
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
|
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 => {
|
export const isReplaceableEvent = (event: Event): boolean => {
|
||||||
return event.kind === EventKinds.SET_METADATA
|
return event.kind === EventKinds.SET_METADATA
|
||||||
|| event.kind === EventKinds.CONTACT_LIST
|
|| 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 { mergeDeepRight } from 'ramda'
|
||||||
|
|
||||||
import { createLogger } from '../factories/logger-factory'
|
import { createLogger } from '../factories/logger-factory'
|
||||||
|
import defaultSettingsJson from '../../resources/default-settings.json'
|
||||||
import { ISettings } from '../@types/settings'
|
import { ISettings } from '../@types/settings'
|
||||||
import settingsSampleJson from '../../settings.sample.json'
|
|
||||||
|
|
||||||
const debug = createLogger('settings')
|
const debug = createLogger('settings')
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ export class SettingsStatic {
|
|||||||
}
|
}
|
||||||
debug('creating settings')
|
debug('creating settings')
|
||||||
const path = SettingsStatic.getSettingsFilePath()
|
const path = SettingsStatic.getSettingsFilePath()
|
||||||
const defaults = settingsSampleJson as ISettings
|
const defaults = defaultSettingsJson as ISettings
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (fs.existsSync(path)) {
|
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 toJSON = (input: any) => JSON.stringify(input)
|
||||||
|
|
||||||
export const toBuffer = (input: any) => Buffer.from(input, 'hex')
|
export const toBuffer = (input: any) => Buffer.from(input, 'hex')
|
||||||
|
|
||||||
export const fromBuffer = (input: Buffer) => input.toString('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: ""
|
DEBUG: ""
|
||||||
volumes:
|
volumes:
|
||||||
- ../../package.json:/code/package.json
|
- ../../package.json:/code/package.json
|
||||||
- ../../settings.sample.json:/code/settings.sample.json
|
- ../../resources/default-settings.json:/code/resources/default-settings.json
|
||||||
- ../../src:/code/src
|
- ../../src:/code/src
|
||||||
- ../../test/integration:/code/test/integration
|
- ../../test/integration:/code/test/integration
|
||||||
- ../../cucumber.js:/code/cucumber.js
|
- ../../cucumber.js:/code/cucumber.js
|
||||||
|
@ -9,6 +9,7 @@ chai.use(chaiAsPromised)
|
|||||||
import { EventLimits, ISettings } from '../../../src/@types/settings'
|
import { EventLimits, ISettings } from '../../../src/@types/settings'
|
||||||
import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
|
import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
|
||||||
import { Event } from '../../../src/@types/event'
|
import { Event } from '../../../src/@types/event'
|
||||||
|
import { EventKinds } from '../../../src/constants/base'
|
||||||
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
|
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
|
||||||
import { IWebSocketAdapter } from '../../../src/@types/adapters'
|
import { IWebSocketAdapter } from '../../../src/@types/adapters'
|
||||||
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
|
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
|
||||||
@ -174,6 +175,7 @@ describe('EventMessageHandler', () => {
|
|||||||
whitelist: [],
|
whitelist: [],
|
||||||
},
|
},
|
||||||
pubkey: {
|
pubkey: {
|
||||||
|
minBalanceMsats: 0,
|
||||||
minLeadingZeroBits: 0,
|
minLeadingZeroBits: 0,
|
||||||
blacklist: [],
|
blacklist: [],
|
||||||
whitelist: [],
|
whitelist: [],
|
||||||
@ -241,8 +243,8 @@ describe('EventMessageHandler', () => {
|
|||||||
|
|
||||||
describe('content', () => {
|
describe('content', () => {
|
||||||
describe('maxLength', () => {
|
describe('maxLength', () => {
|
||||||
it('returns undefined if maxLength is zero', () => {
|
it('returns undefined if maxLength is disabled', () => {
|
||||||
eventLimits.content.maxLength = 0
|
eventLimits.content = [{ maxLength: 0 }]
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(handler as any).canAcceptEvent(event)
|
(handler as any).canAcceptEvent(event)
|
||||||
@ -250,7 +252,62 @@ describe('EventMessageHandler', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns undefned if content is not too long', () => {
|
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)
|
event.content = 'x'.repeat(100)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@ -259,12 +316,48 @@ describe('EventMessageHandler', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns reason if content is too long', () => {
|
it('returns reason if content is too long', () => {
|
||||||
eventLimits.content.maxLength = 100
|
eventLimits.content = { maxLength: 1 }
|
||||||
event.content = 'x'.repeat(101)
|
event.content = 'xx'
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(handler as any).canAcceptEvent(event)
|
(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,
|
rate: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kinds: [0],
|
kinds: [1],
|
||||||
period: 60000,
|
period: 60000,
|
||||||
rate: 2,
|
rate: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kinds: [[10, 20]],
|
kinds: [[0, 3]],
|
||||||
period: 86400000,
|
period: 86400000,
|
||||||
rate: 3,
|
rate: 3,
|
||||||
},
|
},
|
||||||
@ -689,7 +782,7 @@ describe('EventMessageHandler', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly(
|
expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly(
|
||||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[0]',
|
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[1]',
|
||||||
1,
|
1,
|
||||||
{
|
{
|
||||||
period: 60000,
|
period: 60000,
|
||||||
@ -697,7 +790,7 @@ describe('EventMessageHandler', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
expect(rateLimiterHitStub.thirdCall).to.have.been.calledWithExactly(
|
expect(rateLimiterHitStub.thirdCall).to.have.been.calledWithExactly(
|
||||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[10,20]]',
|
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[0,3]]',
|
||||||
1,
|
1,
|
||||||
{
|
{
|
||||||
period: 86400000,
|
period: 86400000,
|
||||||
@ -729,7 +822,14 @@ describe('EventMessageHandler', () => {
|
|||||||
|
|
||||||
const actualResult = await (handler as any).isRateLimited(event)
|
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
|
expect(actualResult).to.be.false
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -745,19 +845,33 @@ describe('EventMessageHandler', () => {
|
|||||||
rate: 2,
|
rate: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kinds: [[10, 20]],
|
kinds: [[0, 5]],
|
||||||
period: 86400000,
|
period: 180,
|
||||||
rate: 3,
|
rate: 3,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
rateLimiterHitStub.onFirstCall().resolves(false)
|
rateLimiterHitStub.onFirstCall().resolves(false)
|
||||||
rateLimiterHitStub.onSecondCall().resolves(true)
|
rateLimiterHitStub.onSecondCall().resolves(true)
|
||||||
rateLimiterHitStub.onThirdCall().resolves(false)
|
|
||||||
|
|
||||||
const actualResult = await (handler as any).isRateLimited(event)
|
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
|
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,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "es6",
|
"target": "es2020",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"types": ["node", "mocha", "@cucumber/cucumber"],
|
"types": ["node", "mocha", "@cucumber/cucumber"],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user