feat: add pay-to-relay

This commit is contained in:
Ricardo Arturo Cabral Mejía 2023-01-10 20:51:49 -05:00
parent 779f7b7fe6
commit 2618a4d2dc
53 changed files with 3325 additions and 151 deletions

View File

@ -1,19 +1,70 @@
{
"info": {
"relay_url": "wss://nostream.localtest.me",
"name": "nostream.localtest.me",
"description": "A nostr relay written in TypeScript.",
"pubkey": "replace-with-your-pubkey",
"relay_url": "wss://nostr-relay-dev.wlvs.space",
"name": "nostr-relay-dev.wlvs.space",
"description": "A nostr relay written in Typescript.",
"pubkey": "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700",
"contact": "operator@your-domain.com"
},
"network": {
"max_payload_size": 131072,
"remote_ip_header": "x-forwarded-for"
"maxPayloadSize": 131072,
"remoteIpHeader": "x-forwarded-for",
"idleTimeout": 60
},
"payments": {
"enabled": true,
"processor": "zebedee",
"feeSchedules": {
"admission": [{
"enabled": true,
"descripton": "Admission fee in msats (1000 msats = 1 satoshi)",
"amount": 1000000,
"whitelists": {
"pubkeys": ["replace-with-your-pubkey"]
}
}],
"publication": [
{
"enabled": true,
"description": "Publication fee in msats (1000 msats = 1 satoshi)",
"amount": 100,
"whitelists": {
"pubkeys": ["replace-with-your-pubkey"]
}
}
]
}
},
"paymentProcessors": {
"zebedee": {
"baseURL": "https://api.zebedee.io/",
"callbackBaseURL": "https://nostr-relay-dev.wlvs.space/callbacks/zebedee"
}
},
"workers": {
"count": 0
},
"limits": {
"connection": {
"rateLimits": [
{
"period": 60000,
"rate": 12
},
{
"period": 3600000,
"rate": 360
},
{
"period": 86400000,
"rate": 2880
}
],
"ipWhitelist": [
"::1",
"::ffff:10.10.10.1"
]
},
"event": {
"eventId": {
"minLeadingZeroBits": 0
@ -31,45 +82,62 @@
"maxPositiveDelta": 900,
"maxNegativeDelta": 0
},
"content": {
"maxLength": 1048576
},
"content": [
{
"description": "64 KB for event kind ranges 0-10 and 40-49",
"kinds": [[0, 10], [40, 49]],
"maxLength": 65536
},
{
"description": "96 KB for event kind ranges 11-39 and 50-max",
"kinds": [[11, 39], [50, 9007199254740991]],
"maxLength": 98304
}
],
"rateLimits": [
{
"description": "6 events/min for event kinds 0, 3, 40 and 41",
"kinds": [0, 3, 40, 41],
"period": 60000,
"rate": 6
},
{
"description": "12 events/min for event kinds 1, 2, 4 and 42",
"kinds": [1, 2, 4, 42],
"period": 60000,
"rate": 12
},
{
"description": "360 events/hour for event kinds 1, 2, 4 and 42",
"kinds": [1, 2, 4, 42],
"period": 3600000,
"rate": 360
},
{
"description": "30 events/min for event kind ranges 5-7 and 43-49",
"kinds": [[5, 7], [43, 49]],
"period": 60000,
"rate": 30
},
{
"description": "24 events/min for replaceable events and parameterized replaceable events",
"kinds": [[10000, 19999], [30000, 39999]],
"period": 60000,
"rate": 24
},
{
"description": "60 events/min for ephemeral events",
"kinds": [[20000, 29999]],
"period": 60000,
"rate": 60
},
{
"description": "720 events/hour for all events",
"period": 3600000,
"rate": 720
},
{
"description": "2880 events/day for all events",
"period": 86400000,
"rate": 2880
}
@ -91,14 +159,29 @@
"message": {
"rateLimits": [
{
"description": "60 subscriptions/min",
"types": ["REQ"],
"period": 60000,
"rate": 60
},
{
"description": "2880 subscriptions/hour",
"types": ["REQ"],
"period": 3600000,
"rate": 2880
},
{
"description": "120 raw messages/min",
"period": 60000,
"rate": 120
},
{
"description": "3600 raw messages/hour",
"period": 3600000,
"rate": 3600
},
{
"description": "86400 raw messages/day",
"period": 86400000,
"rate": 86400
}

View File

@ -47,8 +47,8 @@ Running `nostream` for the first time creates the settings file in `<project_roo
| info.description | Public description of your relay. (e.g. Toronto Bitcoin Group Public Relay) |
| info.pubkey | Relay operator's Nostr pubkey in hex format. |
| info.contact | Relay operator's contact. (e.g. mailto:operator@relay-your-domain.com) |
| network.max_payload_size | Maximum number of bytes accepted per WebSocket frame |
| network.remote_ip_header | HTTP header from proxy containing IP address from client. |
| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
| workers.count | Number of workers to spin up to handle incoming connections. |
| | Spin workers as many CPUs are available when set to zero. Defaults to zero. |
| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. |
@ -61,7 +61,8 @@ Running `nostream` for the first time creates the settings file in `<project_roo
| limits.event.pubkey.blacklist | List of public keys to always reject. Public keys in this list will not be able to post to this relay. |
| limits.event.createdAt.maxPositiveDelta | Maximum number of seconds an event's `created_at` can be in the future. Defaults to 900 (15 minutes). Disabled when set to zero. |
| limits.event.createdAt.minNegativeDelta | Maximum number of secodns an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
| limits.event.content.maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.content[].kinds | List of event kinds to apply limit. Use `[min, max]` for ranges. Optional. |
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.rateLimits[].kinds | List of event kinds rate limited. Use `[min, max]` for ranges. Optional. |
| limits.event.rateLimits[].period | Rate limiting period in milliseconds. |
| limits.event.rateLimits[].rate | Maximum number of events during period. |

View File

@ -28,6 +28,8 @@ ENV DB_PASSWORD=nostr-ts-relay
WORKDIR /app
RUN apk add --no-cache --update git
ADD resources /app/resources
COPY --from=build /build/dist .
RUN npm install --omit=dev --quiet

View File

@ -3,6 +3,7 @@ services:
build: .
container_name: nostr-ts-relay
environment:
SECRET: changeme
RELAY_PORT: 8008
NOSTR_CONFIG_DIR: /home/node/
# Master
@ -37,6 +38,8 @@ services:
# DEBUG: "primary:*"
# DEBUG: "worker:*"
# DEBUG: "knex:query"
env_file:
- test.env
user: node:node
volumes:
- ${PWD}/.nostr:/home/node/

View File

@ -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;')
}

View 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

File diff suppressed because it is too large Load Diff

View File

@ -78,6 +78,7 @@
"@types/chai": "^4.3.1",
"@types/chai-as-promised": "^7.1.5",
"@types/debug": "4.1.7",
"@types/express": "4.17.15",
"@types/mocha": "^9.1.1",
"@types/node": "^17.0.24",
"@types/pg": "^8.6.5",
@ -109,8 +110,13 @@
},
"dependencies": {
"@noble/secp256k1": "1.7.1",
"axios": "1.2.2",
"bech32": "2.0.0",
"body-parser": "1.20.1",
"debug": "4.3.4",
"dotenv": "^16.0.3",
"express": "4.18.2",
"helmet": "6.0.1",
"joi": "17.7.0",
"knex": "2.4.0",
"pg": "8.8.0",

166
resources/css/style.css Normal file
View 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;
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

135
resources/index.html Normal file
View 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
View 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
View 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>

View File

@ -2,8 +2,10 @@
PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.."
DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml"
DOCKER_COMPOSE_TOR_FILE="${PROJECT_ROOT}/docker-compose.tor.yml"
DOCKER_COMPOSE_LOCAL_FILE="${PROJECT_ROOT}/docker-compose.local.yml"
docker compose \
-f $DOCKER_COMPOSE_FILE \
-f $DOCKER_COMPOSE_TOR_FILE \
-f $DOCKER_COMPOSE_LOCAL_FILE \
down $@

13
scripts/update Executable file
View 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

View File

@ -7,13 +7,34 @@
"contact": "operator@your-domain.com"
},
"network": {
"max_payload_size": 131072,
"remote_ip_header": "x-forwarded-for"
"maxPayloadSize": 131072,
"remoteIpHeader": "x-forwarded-for",
"idleTimeout": 60
},
"workers": {
"count": 0
},
"limits": {
"connection": {
"rateLimits": [
{
"period": 60000,
"rate": 12
},
{
"period": 3600000,
"rate": 360
},
{
"period": 86400000,
"rate": 2880
}
],
"ipWhitelist": [
"::1",
"::ffff:10.10.10.1"
]
},
"event": {
"eventId": {
"minLeadingZeroBits": 0
@ -23,6 +44,7 @@
"blacklist": []
},
"pubkey": {
"minBalanceMsats": 10000,
"minLeadingZeroBits": 0,
"whitelist": [],
"blacklist": []
@ -109,4 +131,4 @@
]
}
}
}
}

19
src/@types/clients.ts Normal file
View 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>
}

View File

@ -0,0 +1,5 @@
import { Request, Response } from 'express'
export interface IController {
handleRequest(request: Request, response: Response): Promise<void>
}

View File

@ -12,6 +12,10 @@ export interface Event {
content: string
}
export type UnsignedEvent = Omit<Event, 'sig'>
export type UnidentifiedEvent = Omit<UnsignedEvent, 'id'>
export interface DelegatedEvent extends Event {
[EventDelegatorMetadataKey]?: Pubkey
}

42
src/@types/invoice.ts Normal file
View 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
}

View File

@ -2,6 +2,7 @@ import { PassThrough } from 'stream'
import { DBEvent, Event } from './event'
import { EventId, Pubkey } from './base'
import { Invoice } from './invoice'
import { SubscriptionFilter } from './subscription'
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'
@ -17,3 +18,8 @@ export interface IEventRepository {
insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise<number>
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
}
export interface IInvoiceRepository {
findById(id: string): Promise<Invoice | undefined>
upsert(invoice: Invoice): Promise<number>
}

View File

@ -1,4 +1,5 @@
import { EventKinds } from '../constants/base'
import { MessageType } from './messages'
import { Pubkey } from './base'
export interface Info {
@ -10,8 +11,14 @@ export interface Info {
}
export interface Network {
max_payload_size?: number
remote_ip_header?: string
maxPayloadSize?: number
remoteIpHeader?: string
}
export interface RateLimit {
description?: string
period: number
rate: number
}
export interface EventIdLimits {
@ -19,6 +26,7 @@ export interface EventIdLimits {
}
export interface PubkeyLimits {
minBalanceMsats: number
minLeadingZeroBits: number
whitelist?: Pubkey[]
blacklist?: Pubkey[]
@ -26,10 +34,8 @@ export interface PubkeyLimits {
export type EventKindsRange = [EventKinds, EventKinds]
export interface EventRateLimit {
export interface EventRateLimit extends RateLimit {
kinds?: (EventKinds | [EventKinds, EventKinds])[]
rate: number
period: number
}
export interface KindLimits {
@ -49,6 +55,11 @@ export interface CreatedAtLimits {
}
export interface ContentLimits {
description?: string
kinds?: (EventKinds | EventKindsRange)[]
/**
* Maximum number of characters allowed on events
*/
maxLength?: number
}
@ -62,7 +73,7 @@ export interface EventLimits {
pubkey?: PubkeyLimits
kind?: KindLimits
createdAt?: CreatedAtLimits
content?: ContentLimits
content?: ContentLimits | ContentLimits[]
rateLimits?: EventRateLimit[]
whitelists?: EventWhitelists
}
@ -76,9 +87,8 @@ export interface ClientLimits {
subscription?: ClientSubscriptionLimits
}
export interface MessageRateLimit {
rate: number
period: number
export interface MessageRateLimit extends RateLimit {
type?: MessageType
}
export interface MessageLimits {
@ -86,7 +96,19 @@ export interface MessageLimits {
ipWhitelist?: string[]
}
export interface ConnectionLimits {
rateLimits: RateLimit[]
ipWhitelist?: string[]
}
export interface InvoiceLimits {
rateLimits: RateLimit[]
ipWhitelist?: string[]
}
export interface Limits {
invoice?: InvoiceLimits
connection?: ConnectionLimits
client?: ClientLimits
event?: EventLimits
message?: MessageLimits
@ -96,9 +118,42 @@ export interface Worker {
count: number
}
export interface FeeScheduleWhitelists {
pubkeys?: Pubkey[]
}
export interface FeeSchedule {
enabled: boolean
description?: string
amount: number
whitelists?: FeeScheduleWhitelists
}
export interface FeeSchedules {
admission: FeeSchedule[]
publication: FeeSchedule[]
}
export interface Payments {
enabled: boolean
processor: keyof PaymentProcessors
feeSchedules: FeeSchedules
}
export interface ZebedeePaymentProcessor {
baseURL: string
callbackBaseURL: string
}
export interface PaymentProcessors {
zebedee?: ZebedeePaymentProcessor
}
export interface ISettings {
info: Info
network?: Network
payments?: Payments
paymentProcessors?: PaymentProcessors
network: Network
workers?: Worker
limits?: Limits
}

View File

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

View File

@ -13,6 +13,7 @@ import { attemptValidation } from '../utils/validation'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { getRemoteAddress } from '../utils/http'
import { IRateLimiter } from '../@types/utils'
import { ISettings } from '../@types/settings'
import { isEventMatchingFilter } from '../utils/event'
@ -42,8 +43,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.subscriptions = new Map()
this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex')
const remoteIpHeader = this.settings().network?.remote_ip_header ?? 'x-forwarded-for'
this.clientAddress = (this.request.headers[remoteIpHeader] ?? this.request.socket.remoteAddress) as string
this.clientAddress = getRemoteAddress(this.request, this.settings())
this.client
.on('message', this.onClientMessage.bind(this))
@ -203,10 +204,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
rateLimiter.hit(
`${client}:message:${period}`,
1,
{ period: period, rate: rate },
{ period, rate },
)
let limited = false
for (const { rate, period } of rateLimits) {
const isRateLimited = await hit(period, rate)
@ -214,11 +215,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
if (isRateLimited) {
debug('rate limited %s: %d messages / %d ms exceeded', client, rate, period)
return true
limited = true
}
}
return false
return limited
}
private onClientPong() {

View File

@ -6,6 +6,7 @@ import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { IRateLimiter } from '../@types/utils'
import { ISettings } from '../@types/settings'
import { propEq } from 'ramda'
import { WebServerAdapter } from './web-server-adapter'
@ -26,9 +27,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
IWebSocketAdapter,
[WebSocket, IncomingMessage, IWebSocketServerAdapter]
>,
slidingWindowRateLimiter: Factory<IRateLimiter>,
settings: () => ISettings,
) {
super(webServer, settings)
super(webServer, slidingWindowRateLimiter, settings)
this.webSocketsAdapters = new WeakMap()

View File

@ -14,6 +14,9 @@ export enum EventKinds {
CHANNEL_MUTE_USER = 44,
CHANNEL_RESERVED_FIRST = 45,
CHANNEL_RESERVED_LAST = 49,
// Relay-only
RELAY_INVITE = 50,
INVOICE_UPDATE = 402,
// Replaceable events
REPLACEABLE_FIRST = 10000,
REPLACEABLE_LAST = 19999,
@ -35,5 +38,9 @@ export enum EventTags {
Deduplication = 'd',
}
export enum PaymentsProcessors {
ZEBEDEE = 'zebedee',
}
export const EventDelegatorMetadataKey = Symbol('Delegator')
export const EventDeduplicationMetadataKey = Symbol('Deduplication')

View 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,
})
}
}
}

View 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()
}
}

View File

@ -1,3 +1,5 @@
import express from 'express'
import helmet from 'helmet'
import http from 'http'
import process from 'process'
import { WebSocketServer } from 'ws'
@ -6,6 +8,8 @@ import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
import { AppWorker } from '../app/worker'
import { createSettings } from '../factories/settings-factory'
import { EventRepository } from '../repositories/event-repository'
import router from '../routes'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
import { webSocketAdapterFactory } from './websocket-adapter-factory'
import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter'
@ -14,16 +18,47 @@ export const workerFactory = (): AppWorker => {
const readReplicaDbClient = getReadReplicaDbClient()
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
const app = express()
app
.disable('x-powered-by')
.use( helmet.contentSecurityPolicy({
directives: {
/**
* TODO: Remove 'unsafe-inline'
*/
'script-src-attr': ["'unsafe-inline'"],
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/', 'https://cdnjs.cloudflare.com/ajax/libs/'],
'style-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
},
}))
.use('/favicon.ico', express.static('./resources/favicon.ico'))
.use('/css', express.static('./resources/css'))
.use(router)
// deepcode ignore HttpToHttps: we use proxies
const server = http.createServer()
const server = http.createServer(app)
const settings = createSettings()
let maxPayloadSize: number | undefined
if (settings.network['max_payload_size']) {
console.warn(`WARNING: Setting network.max_payload_size is deprecated and will be removed in a future version.
Use network.maxPayloadSize instead.`)
maxPayloadSize = settings.network['max_payload_size']
} else {
maxPayloadSize = settings.network.maxPayloadSize
}
const webSocketServer = new WebSocketServer({
server,
maxPayload: createSettings().network?.max_payload_size ?? 131072, // 128 kB
maxPayload: maxPayloadSize ?? 131072, // 128 kB
})
const adapter = new WebSocketServerAdapter(
server,
webSocketServer,
webSocketAdapterFactory(eventRepository),
slidingWindowRateLimiterFactory,
createSettings,
)

View 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,
)
}

View File

@ -1,10 +1,9 @@
import { EventKindsRange, EventRateLimit, ISettings } from '../@types/settings'
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventSignatureValid } from '../utils/event'
import { EventRateLimit, ISettings } from '../@types/settings'
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid } from '../utils/event'
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
import { createCommandResult } from '../utils/messages'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { EventKinds } from '../constants/base'
import { Factory } from '../@types/base'
import { IncomingEventMessage } from '../@types/messages'
import { IRateLimiter } from '../@types/utils'
@ -61,27 +60,63 @@ export class EventMessageHandler implements IMessageHandler {
protected canAcceptEvent(event: Event): string | undefined {
const now = Math.floor(Date.now()/1000)
const limits = this.settings().limits.event
if (limits.content.maxLength > 0 && event.content.length > limits.content.maxLength) {
const limits = this.settings().limits?.event ?? {}
if (Array.isArray(limits.content)) {
for (const limit of limits.content) {
if (
typeof limit.maxLength !== 'undefined'
&& limit.maxLength > 0
&& event.content.length > limit.maxLength
&& (
!Array.isArray(limit.kinds)
|| limit.kinds.some(isEventKindOrRangeMatch(event))
)
) {
return `rejected: content is longer than ${limit.maxLength} bytes`
}
}
} else if (
typeof limits.content?.maxLength !== 'undefined'
&& limits.content?.maxLength > 0
&& event.content.length > limits.content.maxLength
&& (
!Array.isArray(limits.content.kinds)
|| limits.content.kinds.some(isEventKindOrRangeMatch(event))
)
) {
return `rejected: content is longer than ${limits.content.maxLength} bytes`
}
if (limits.createdAt.maxPositiveDelta > 0 && event.created_at > now + limits.createdAt.maxPositiveDelta) {
if (
typeof limits.createdAt?.maxPositiveDelta !== 'undefined'
&& limits.createdAt.maxPositiveDelta > 0
&& event.created_at > now + limits.createdAt.maxPositiveDelta) {
return `rejected: created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future`
}
if (limits.createdAt.maxNegativeDelta > 0 && event.created_at < now - limits.createdAt.maxNegativeDelta) {
if (
typeof limits.createdAt?.maxNegativeDelta !== 'undefined'
&& limits.createdAt.maxNegativeDelta > 0
&& event.created_at < now - limits.createdAt.maxNegativeDelta) {
return `rejected: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`
}
if (limits.eventId.minLeadingZeroBits > 0) {
if (
typeof limits.eventId?.minLeadingZeroBits !== 'undefined'
&& limits.eventId.minLeadingZeroBits > 0
) {
const pow = getEventProofOfWork(event.id)
if (pow < limits.eventId.minLeadingZeroBits) {
return `pow: difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`
}
}
if (limits.pubkey.minLeadingZeroBits > 0) {
if (
typeof limits.pubkey?.minLeadingZeroBits !== 'undefined'
&& limits.pubkey.minLeadingZeroBits > 0
) {
const pow = getPubkeyProofOfWork(event.pubkey)
if (pow < limits.pubkey.minLeadingZeroBits) {
return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`
@ -89,29 +124,32 @@ export class EventMessageHandler implements IMessageHandler {
}
if (
limits.pubkey.whitelist.length > 0
typeof limits.pubkey?.whitelist !== 'undefined'
&& limits.pubkey.whitelist.length > 0
&& !limits.pubkey.whitelist.some((prefix) => event.pubkey.startsWith(prefix))
) {
return 'blocked: pubkey not allowed'
}
if (
limits.pubkey.blacklist.length > 0
typeof limits.pubkey?.blacklist !== 'undefined'
&& limits.pubkey.blacklist.length > 0
&& limits.pubkey.blacklist.some((prefix) => event.pubkey.startsWith(prefix))
) {
return 'blocked: pubkey not allowed'
}
const isEventKindMatch = (item: EventKinds | EventKindsRange) =>
typeof item === 'number'
? item === event.kind
: event.kind >= item[0] && event.kind <= item[1]
if (limits.kind.whitelist.length > 0 && !limits.kind.whitelist.some(isEventKindMatch)) {
if (
typeof limits.kind?.whitelist !== 'undefined'
&& limits.kind.whitelist.length > 0
&& !limits.kind.whitelist.some(isEventKindOrRangeMatch(event))) {
return `blocked: event kind ${event.kind} not allowed`
}
if (limits.kind.blacklist.length > 0 && limits.kind.blacklist.some(isEventKindMatch)) {
if (
typeof limits.kind?.blacklist !== 'undefined'
&& limits.kind.blacklist.length > 0
&& limits.kind.blacklist.some(isEventKindOrRangeMatch(event))) {
return `blocked: event kind ${event.kind} not allowed`
}
}
@ -132,13 +170,16 @@ export class EventMessageHandler implements IMessageHandler {
}
if (
Array.isArray(whitelists?.pubkeys)
typeof whitelists?.pubkeys !== 'undefined'
&& Array.isArray(whitelists?.pubkeys)
&& whitelists.pubkeys.includes(event.pubkey)
) {
return false
}
if (Array.isArray(whitelists?.ipAddresses)
if (
typeof whitelists?.ipAddresses !== 'undefined'
&& Array.isArray(whitelists?.ipAddresses)
&& whitelists.ipAddresses.includes(this.webSocket.getClientAddress())
) {
return false
@ -162,10 +203,22 @@ export class EventMessageHandler implements IMessageHandler {
)
}
const hits = await Promise.all(rateLimits.map(hit))
let limited = false
for (const { rate, period, kinds } of rateLimits) {
// skip if event kind does not apply
if (Array.isArray(kinds) && !kinds.some(isEventKindOrRangeMatch(event))) {
continue
}
debug('rate limit check %s: %o', event.pubkey, hits)
const isRateLimited = await hit({ period, rate, kinds })
return hits.some((active) => active)
if (isRateLimited) {
debug('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period)
limited = true
}
}
return limited
}
}

View 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()
}

View 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()
}

View 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()
}

View File

@ -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)
}

View 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()
}

View 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: '',
},
}
}
}

View 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)
}
}

View 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
}
}
}

View 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>
}
}

View 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
View 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

View 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

View 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)

View File

@ -1,16 +1,18 @@
import * as secp256k1 from '@noble/secp256k1'
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
import { createHmac } from 'crypto'
import { CanonicalEvent, DBEvent, Event } from '../@types/event'
import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event'
import { EventId, Pubkey, Tag } from '../@types/base'
import { EventKinds, EventTags } from '../constants/base'
import { EventKindsRange } from '../@types/settings'
import { fromBuffer } from './transform'
import { getLeadingZeroBits } from './proof-of-work'
import { isGenericTagQuery } from './filter'
import { RuneLike } from './runes/rune-like'
import { SubscriptionFilter } from '../@types/subscription'
export const serializeEvent = (event: Event): CanonicalEvent => [
export const serializeEvent = (event: UnidentifiedEvent): CanonicalEvent => [
0,
event.pubkey,
event.created_at,
@ -29,6 +31,12 @@ export const toNostrEvent: (event: DBEvent) => Event = applySpec({
sig: pipe(prop('event_signature') as () => Buffer, fromBuffer),
})
export const isEventKindOrRangeMatch = ({ kind }: Event) =>
(item: EventKinds | EventKindsRange) =>
typeof item === 'number'
? item === kind
: kind >= item[0] && kind <= item[1]
export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => {
const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix)
@ -149,16 +157,42 @@ export const isDelegatedEventValid = async (event: Event): Promise<boolean> => {
return secp256k1.schnorr.verify(delegation[3], token, delegation[1])
}
export const isEventIdValid = async (event: Event): Promise<boolean> => {
export const getEventHash = async (event: Event | UnidentifiedEvent | UnsignedEvent): Promise<string> => {
const id = await secp256k1.utils.sha256(Buffer.from(JSON.stringify(serializeEvent(event))))
return Buffer.from(id).toString('hex') === event.id
return Buffer.from(
id
).toString('hex')
}
export const isEventIdValid = async (event: Event): Promise<boolean> => {
return event.id === await getEventHash(event)
}
export const isEventSignatureValid = async (event: Event): Promise<boolean> => {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export const identifyEvent = async (event: UnidentifiedEvent): Promise<UnsignedEvent> => {
const id = await getEventHash(event)
return { ...event, id }
}
export const getPrivateKeyFromSecret =
(secret: string) => (publicKey: Pubkey | Buffer): string => {
const hmac = createHmac('sha256', secret)
hmac.update(typeof publicKey === 'string' ? Buffer.from(publicKey, 'hex') : publicKey)
return hmac.digest().toString('hex')
}
export const getPublicKey = (privkey: string | Buffer) => Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2)
export const signEvent = (privkey: string | Buffer | undefined) => async (event: UnsignedEvent): Promise<Event> => {
const sig = await secp256k1.schnorr.sign(event.id, privkey)
return { ...event, sig: Buffer.from(sig).toString('hex') }
}
export const isReplaceableEvent = (event: Event): boolean => {
return event.kind === EventKinds.SET_METADATA
|| event.kind === EventKinds.CONTACT_LIST

17
src/utils/http.ts Normal file
View 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
}

View File

@ -4,8 +4,8 @@ import { join } from 'path'
import { mergeDeepRight } from 'ramda'
import { createLogger } from '../factories/logger-factory'
import defaultSettingsJson from '../../resources/default-settings.json'
import { ISettings } from '../@types/settings'
import settingsSampleJson from '../../settings.sample.json'
const debug = createLogger('settings')
@ -35,7 +35,7 @@ export class SettingsStatic {
}
debug('creating settings')
const path = SettingsStatic.getSettingsFilePath()
const defaults = settingsSampleJson as ISettings
const defaults = defaultSettingsJson as ISettings
try {
if (fs.existsSync(path)) {

View File

@ -1,5 +1,67 @@
import { applySpec, is, path, pathEq, pipe, prop, propSatisfies, when } from 'ramda'
import { bech32 } from 'bech32'
import { DBInvoice, Invoice } from '../@types/invoice'
import { Pubkey } from '../@types/base'
export const toJSON = (input: any) => JSON.stringify(input)
export const toBuffer = (input: any) => Buffer.from(input, 'hex')
export const fromBuffer = (input: Buffer) => input.toString('hex')
export const toBigInt = (input: string): bigint => BigInt(input)
export const fromBigInt = (input: bigint) => input.toString()
export const fromDBInvoice = (input: DBInvoice): Invoice => applySpec<Invoice>({
id: prop('id') as () => Pubkey,
pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer),
bolt11: prop('bolt11'),
amountRequested: pipe(prop('amount_requested'), toBigInt),
amountPaid: pipe(prop('amount_paid'), toBigInt),
unit: prop('unit'),
status: prop('status'),
description: prop('description'),
confirmedAt: prop('confirmed_at'),
expiresAt: prop('expires_at'),
updatedAt: prop('updated_at'),
createdAt: prop('created_at'),
})(input)
export const fromNpub = (npub: string) => {
const { prefix, words } = bech32.decode(npub)
if (prefix !== 'npub') {
throw new Error('not an npub key')
}
return Buffer.from(
bech32.fromWords(words).slice(0, 32)
).toString('hex')
}
export const toDate = (input: string) => new Date(input)
export const fromZebedeeInvoice = applySpec<Invoice>({
id: prop('id'),
pubkey: prop('internalId'),
bolt11: path(['invoice', 'request']),
amountRequested: pipe(prop('amount'), toBigInt),
amountPaid: when(
pathEq(['status'], 'completed'),
pipe(prop('amount'), toBigInt),
),
unit: prop('unit'),
status: prop('status'),
description: prop('description'),
confirmedAt: when(
propSatisfies(is(String), 'confirmed_at'),
pipe(prop('confirmed_at'), toDate),
),
expiresAt: when(
propSatisfies(is(String), 'confirmed_at'),
pipe(prop('expires_at'), toDate),
),
createdAt: pipe(prop('created_at'), toDate),
})

View File

@ -20,7 +20,7 @@ services:
DEBUG: ""
volumes:
- ../../package.json:/code/package.json
- ../../settings.sample.json:/code/settings.sample.json
- ../../resources/default-settings.json:/code/resources/default-settings.json
- ../../src:/code/src
- ../../test/integration:/code/test/integration
- ../../cucumber.js:/code/cucumber.js

View File

@ -9,6 +9,7 @@ chai.use(chaiAsPromised)
import { EventLimits, ISettings } from '../../../src/@types/settings'
import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
import { Event } from '../../../src/@types/event'
import { EventKinds } from '../../../src/constants/base'
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
import { IWebSocketAdapter } from '../../../src/@types/adapters'
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
@ -174,6 +175,7 @@ describe('EventMessageHandler', () => {
whitelist: [],
},
pubkey: {
minBalanceMsats: 0,
minLeadingZeroBits: 0,
blacklist: [],
whitelist: [],
@ -241,8 +243,8 @@ describe('EventMessageHandler', () => {
describe('content', () => {
describe('maxLength', () => {
it('returns undefined if maxLength is zero', () => {
eventLimits.content.maxLength = 0
it('returns undefined if maxLength is disabled', () => {
eventLimits.content = [{ maxLength: 0 }]
expect(
(handler as any).canAcceptEvent(event)
@ -250,7 +252,62 @@ describe('EventMessageHandler', () => {
})
it('returns undefned if content is not too long', () => {
eventLimits.content.maxLength = 100
eventLimits.content = [{ maxLength: 1 }]
event.content = 'x'.repeat(1)
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
it('returns undefined if kind does not match', () => {
eventLimits.content = [{ kinds: [EventKinds.SET_METADATA], maxLength: 1 }]
event.content = 'x'
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
it('returns undefined if kind matches but content is short', () => {
eventLimits.content = [{ kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }]
event.content = 'x'
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
it('returns reason if kind matches but content is too long', () => {
eventLimits.content = [{ kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }]
event.content = 'xx'
expect(
(handler as any).canAcceptEvent(event)
).to.equal('rejected: content is longer than 1 bytes')
})
it('returns reason if content is too long', () => {
eventLimits.content = [{ maxLength: 1 }]
event.content = 'x'.repeat(2)
expect(
(handler as any).canAcceptEvent(event)
).to.equal('rejected: content is longer than 1 bytes')
})
})
describe('maxLength (deprecated)', () => {
it('returns undefined if maxLength is zero', () => {
eventLimits.content = { maxLength: 0 }
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
it('returns undefined if content is short', () => {
eventLimits.content = { maxLength: 100 }
event.content = 'x'.repeat(100)
expect(
@ -259,12 +316,48 @@ describe('EventMessageHandler', () => {
})
it('returns reason if content is too long', () => {
eventLimits.content.maxLength = 100
event.content = 'x'.repeat(101)
eventLimits.content = { maxLength: 1 }
event.content = 'xx'
expect(
(handler as any).canAcceptEvent(event)
).to.equal('rejected: content is longer than 100 bytes')
).to.equal('rejected: content is longer than 1 bytes')
})
it('returns undefined if kind matches and content is short', () => {
eventLimits.content = { kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }
event.content = 'x'
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
it('returns undefined if kind does not match and content is too long', () => {
eventLimits.content = { kinds: [EventKinds.SET_METADATA], maxLength: 1 }
event.content = 'xx'
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
it('returns reason if content is too long', () => {
eventLimits.content = { maxLength: 1 }
event.content = 'xx'
expect(
(handler as any).canAcceptEvent(event)
).to.equal('rejected: content is longer than 1 bytes')
})
it('returns undefined if content is not set', () => {
eventLimits.content = undefined
event.content = 'xx'
expect(
(handler as any).canAcceptEvent(event)
).to.be.undefined
})
})
@ -666,12 +759,12 @@ describe('EventMessageHandler', () => {
rate: 1,
},
{
kinds: [0],
kinds: [1],
period: 60000,
rate: 2,
},
{
kinds: [[10, 20]],
kinds: [[0, 3]],
period: 86400000,
rate: 3,
},
@ -689,7 +782,7 @@ describe('EventMessageHandler', () => {
}
)
expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly(
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[0]',
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[1]',
1,
{
period: 60000,
@ -697,7 +790,7 @@ describe('EventMessageHandler', () => {
}
)
expect(rateLimiterHitStub.thirdCall).to.have.been.calledWithExactly(
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[10,20]]',
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[0,3]]',
1,
{
period: 86400000,
@ -729,7 +822,14 @@ describe('EventMessageHandler', () => {
const actualResult = await (handler as any).isRateLimited(event)
expect(rateLimiterHitStub).to.have.been.calledThrice
expect(rateLimiterHitStub).to.have.been.calledOnceWithExactly(
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000',
1,
{
period: 60000,
rate: 1,
},
)
expect(actualResult).to.be.false
})
@ -745,19 +845,33 @@ describe('EventMessageHandler', () => {
rate: 2,
},
{
kinds: [[10, 20]],
period: 86400000,
kinds: [[0, 5]],
period: 180,
rate: 3,
},
]
rateLimiterHitStub.onFirstCall().resolves(false)
rateLimiterHitStub.onSecondCall().resolves(true)
rateLimiterHitStub.onThirdCall().resolves(false)
const actualResult = await (handler as any).isRateLimited(event)
expect(rateLimiterHitStub).to.have.been.calledThrice
expect(rateLimiterHitStub.firstCall).to.have.been.calledWithExactly(
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000',
1,
{
period: 60000,
rate: 1,
},
)
expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly(
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:180:[[0,5]]',
1,
{
period: 180,
rate: 3,
},
)
expect(actualResult).to.be.true
})
})

View 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
})
})

View File

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