From b7840ced089bb30a40e97d80ee2adfaf984a579d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 10:10:55 +0200 Subject: [PATCH 001/114] chore: debug log --- client_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client_manager.py b/client_manager.py index 8b4ac2c..f5c8fec 100644 --- a/client_manager.py +++ b/client_manager.py @@ -155,7 +155,7 @@ class NostrClientConnection: async def _handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["OK", e.id] - + logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") valid, message = self._validate_event(e) if not valid: resp_nip20 + [valid, message] From 330fceaf3476cfed27724283000ee6f3e1cd71b5 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 11:50:18 +0200 Subject: [PATCH 002/114] feat: small UI improvements --- templates/nostrrelay/public.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 1a7a96b..a0ec3c3 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -46,8 +46,10 @@ :content-inset-level="0.5" default-opened > -
- +
+
+
+
+
+
@@ -167,6 +171,7 @@ pubkey: this.pubkey } ) + console.log('### data.invoice', data.invoice) this.joinInvoice = data.invoice } catch (error) { LNbits.utils.notifyApiError(error) From bd5957b4437a30d1836cfd2a97fc40a5e77f6345 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 14:12:37 +0200 Subject: [PATCH 003/114] feat: update account when join invoce paid --- __init__.py | 13 +++++------ client_manager.py | 1 - crud.py | 58 +++++++++++++++++++++++++++++++++++++++++++++-- migrations.py | 14 ++++++++++++ models.py | 13 +++++++++++ tasks.py | 48 +++++++++++++++++++++++++++++++++++++++ views_api.py | 2 +- 7 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 tasks.py diff --git a/__init__.py b/__init__.py index 7da5850..b376e2e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,10 @@ +import asyncio from fastapi import APIRouter from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer -from lnbits.settings import settings +from lnbits.tasks import catch_everything_and_restart db = Database("ext_nostrrelay") @@ -22,12 +23,10 @@ def nostrrelay_renderer(): return template_renderer(["lnbits/extensions/nostrrelay/templates"]) -from .models import NostrRelay +from .tasks import wait_for_paid_invoices from .views import * # noqa from .views_api import * # noqa -# settings.lnbits_relay_information = { -# "name": "LNbits Nostr Relay", -# "description": "Multiple relays are supported", -# **NostrRelay.info(), -# } +def nostrrelay_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/client_manager.py b/client_manager.py index f5c8fec..f7b06cf 100644 --- a/client_manager.py +++ b/client_manager.py @@ -11,7 +11,6 @@ from .crud import ( get_config_for_all_active_relays, get_event, get_events, - get_prunable_events, get_storage_for_public_key, mark_events_deleted, prune_old_events, diff --git a/crud.py b/crud.py index 6a859b9..14a1016 100644 --- a/crud.py +++ b/crud.py @@ -2,11 +2,10 @@ import json from typing import Any, List, Optional, Tuple from . import db -from .models import NostrEvent, NostrFilter, NostrRelay, RelayPublicSpec, RelaySpec +from .models import NostrAccount, NostrEvent, NostrFilter, NostrRelay, RelayPublicSpec, RelaySpec ########################## RELAYS #################### - async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.execute( """ @@ -318,3 +317,58 @@ def build_select_events_query(relay_id: str, filter: NostrFilter): query += f" LIMIT {filter.limit}" return query, values + + + +########################## ACCOUNTS #################### + +async def create_account(relay_id: str, a: NostrAccount) -> NostrAccount: + await db.execute( + """ + INSERT INTO nostrrelay.accounts (relay_id, pubkey, sats, storage, paid_to_join, allowed, blocked) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + relay_id, + a.pubkey, + a.sats, + a.storage, + a.paid_to_join, + a.allowed, + a.blocked, + ), + ) + account = await get_account(relay_id, a.pubkey) + assert account, "Created account cannot be retrieved" + return account + + +async def update_account(relay_id: str, a: NostrAccount) -> NostrAccount: + await db.execute( + """ + UPDATE nostrrelay.accounts + SET (sats, storage, paid_to_join, allowed, blocked) = (?, ?, ?, ?, ?) + WHERE relay_id = ? AND pubkey = ? + """, + ( + a.sats, + a.storage, + a.paid_to_join, + a.allowed, + a.blocked, + relay_id, + a.pubkey + ), + ) + + return a + + +async def get_account(relay_id: str, pubkey: str,) -> Optional[NostrAccount]: + row = await db.fetchone( + "SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND pubkey = ?", + (relay_id, pubkey), + ) + + return NostrAccount.from_row(row) if row else None + diff --git a/migrations.py b/migrations.py index e4837e8..6c122cc 100644 --- a/migrations.py +++ b/migrations.py @@ -45,3 +45,17 @@ async def m001_initial(db): ); """ ) + + await db.execute( + f""" + CREATE TABLE nostrrelay.accounts ( + relay_id TEXT NOT NULL, + pubkey TEXT NOT NULL, + sats {db.big_int} DEFAULT 0, + storage {db.big_int} DEFAULT 0, + paid_to_join BOOLEAN DEFAULT false, + allowed BOOLEAN DEFAULT false, + blocked BOOLEAN DEFAULT false + ); + """ + ) diff --git a/models.py b/models.py index 47c3dde..071c3fe 100644 --- a/models.py +++ b/models.py @@ -310,3 +310,16 @@ class NostrFilter(BaseModel): class RelayJoin(BaseModel): relay_id: str pubkey: str + + +class NostrAccount(BaseModel): + pubkey: str + sats = 0 + storage = 0 + paid_to_join = False + allowed = False + blocked = False + + @classmethod + def from_row(cls, row: Row) -> "NostrAccount": + return cls(**dict(row)) \ No newline at end of file diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..11dbc50 --- /dev/null +++ b/tasks.py @@ -0,0 +1,48 @@ +import asyncio +import re + +from loguru import logger + +from lnbits.core.models import Payment +from lnbits.extensions.nostrrelay.models import NostrAccount +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + +from .crud import create_account, get_account, update_account + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue, get_current_extension_name()) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment): + if payment.extra.get("tag") != "nostrrely": + return + + relay_id = payment.extra.get("relay_id") + pubkey = payment.extra.get("pubkey") + + if payment.extra.get("action") == "join": + await invoice_paid_to_join(relay_id, pubkey) + return + +async def invoice_paid_to_join(relay_id: str, pubkey: str): + try: + account = await get_account(relay_id, pubkey) + if not account: + await create_account(relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True)) + return + + if account.blocked or account.paid_to_join: + return + + account.paid_to_join = True + await update_account(relay_id, account) + + except Exception as ex: + logger.warning(ex) \ No newline at end of file diff --git a/views_api.py b/views_api.py index e4ad15f..d5f98c9 100644 --- a/views_api.py +++ b/views_api.py @@ -176,7 +176,7 @@ async def api_pay_to_join(data: RelayJoin): extra={ "tag": "nostrrely", "action": "join", - "relay": relay.id, + "relay_id": relay.id, "pubkey": pubkey, }, ) From 4970334713d516c253fa216d4d128ba361b3d38e Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 14:20:11 +0200 Subject: [PATCH 004/114] refactor: extract method --- client_manager.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/client_manager.py b/client_manager.py index f7b06cf..89074dd 100644 --- a/client_manager.py +++ b/client_manager.py @@ -155,13 +155,8 @@ class NostrClientConnection: async def _handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["OK", e.id] logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") - valid, message = self._validate_event(e) - if not valid: - resp_nip20 + [valid, message] - await self._send_msg(resp_nip20) - return None - valid, message = await self._validate_storage(e) + valid, message = await self._validate_write(e) if not valid: resp_nip20 += [valid, message] await self._send_msg(resp_nip20) @@ -237,6 +232,17 @@ class NostrClientConnection: and len(self.filters) >= self.client_config.max_client_filters ) + async def _validate_write(self, e: NostrEvent) -> Tuple[bool, str]: + valid, message = self._validate_event(e) + if not valid: + return [valid, message] + + valid, message = await self._validate_storage(e.pubkey, e.size_bytes) + if not valid: + return [valid, message] + + return True, "" + def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]: if self._exceeded_max_events_per_second(): return False, f"Exceeded max events per second limit'!" @@ -258,28 +264,28 @@ class NostrClientConnection: return True, "" - async def _validate_storage(self, e: NostrEvent) -> Tuple[bool, str]: + async def _validate_storage(self, pubkey: str, size_bytes: int) -> Tuple[bool, str]: if self.client_config.free_storage_value == 0: if not self.client_config.is_paid_relay: return False, "Cannot write event, relay is read-only" # todo: handeld paid paid plan return True, "Temp OK" - stored_bytes = await get_storage_for_public_key(self.relay_id, e.pubkey) + stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) if self.client_config.is_paid_relay: # todo: handeld paid paid plan return True, "Temp OK" - if (stored_bytes + e.size_bytes) <= self.client_config.free_storage_bytes_value: + if (stored_bytes + size_bytes) <= self.client_config.free_storage_bytes_value: return True, "" if self.client_config.full_storage_action == "block": return ( False, - f"Cannot write event, no more storage available for public key: '{e.pubkey}'", + f"Cannot write event, no more storage available for public key: '{pubkey}'", ) - await prune_old_events(self.relay_id, e.pubkey, e.size_bytes) + await prune_old_events(self.relay_id, pubkey, size_bytes) return True, "" From 23547f51874eb3eee0257dc36efc0b6b284395e8 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 14:41:33 +0200 Subject: [PATCH 005/114] chore: code format --- __init__.py | 2 ++ crud.py | 9 +++++++- templates/nostrrelay/public.html | 37 ++++++++++++++++++++++---------- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/__init__.py b/__init__.py index b376e2e..4067bb4 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ import asyncio + from fastapi import APIRouter from fastapi.staticfiles import StaticFiles @@ -27,6 +28,7 @@ from .tasks import wait_for_paid_invoices from .views import * # noqa from .views_api import * # noqa + def nostrrelay_start(): loop = asyncio.get_event_loop() loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/crud.py b/crud.py index 14a1016..83cb7fb 100644 --- a/crud.py +++ b/crud.py @@ -2,7 +2,14 @@ import json from typing import Any, List, Optional, Tuple from . import db -from .models import NostrAccount, NostrEvent, NostrFilter, NostrRelay, RelayPublicSpec, RelaySpec +from .models import ( + NostrAccount, + NostrEvent, + NostrFilter, + NostrRelay, + RelayPublicSpec, + RelaySpec, +) ########################## RELAYS #################### diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index a0ec3c3..6c08824 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -24,8 +24,11 @@ > - Pay to joinShow Invoice Cost to join: @@ -46,17 +49,29 @@ :content-inset-level="0.5" default-opened > +
+
+
+ Copy invoice +
+
+
- - - -
+ + + +
@@ -152,7 +167,7 @@ } }, methods: { - payToJoin: async function () { + createJoinInvoice: async function () { if (!this.pubkey) { this.$q.notify({ timeout: 5000, From d0d3f1f6ed46ec434aeab84fa3535ad6053a2a5f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 14:41:43 +0200 Subject: [PATCH 006/114] fix: double column primary key --- migrations.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/migrations.py b/migrations.py index 6c122cc..6b81a94 100644 --- a/migrations.py +++ b/migrations.py @@ -23,13 +23,14 @@ async def m001_initial(db): CREATE TABLE nostrrelay.events ( relay_id TEXT NOT NULL, deleted BOOLEAN DEFAULT false, - id TEXT PRIMARY KEY, + id TEXT NOT NULL, pubkey TEXT NOT NULL, created_at {db.big_int} NOT NULL, kind INT NOT NULL, content TEXT NOT NULL, sig TEXT NOT NULL, - size {db.big_int} DEFAULT 0 + size {db.big_int} DEFAULT 0, + PRIMARY KEY (relay_id, id) ); """ ) @@ -55,7 +56,8 @@ async def m001_initial(db): storage {db.big_int} DEFAULT 0, paid_to_join BOOLEAN DEFAULT false, allowed BOOLEAN DEFAULT false, - blocked BOOLEAN DEFAULT false + blocked BOOLEAN DEFAULT false, + PRIMARY KEY (relay_id, pubkey) ); """ ) From 2233521a43cc4b4e23db2c48ce83b5138e3d0f5d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 16:44:23 +0200 Subject: [PATCH 007/114] feat: ignore some files from release export --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9057ec5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +README.md export-ignore \ No newline at end of file From dfda2367a2c4d6921f426f06313cf7721e5e56ab Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 17:49:10 +0200 Subject: [PATCH 008/114] feat: add payment for stoeage --- README.md | 8 +++ models.py | 6 +- tasks.py | 22 +++++++ templates/nostrrelay/public.html | 107 +++++++++++++++++++++++-------- views_api.py | 22 +++++-- 5 files changed, 130 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d98b0e2..42678f1 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,11 @@ UI for diagnostics and management (key alow/ban lists, rate limiting) coming soo 1. Enable extension 2. Enable relay + + +### Development + +Create Symbolic Link: +``` +ln -s /Users/my-user/git-repos/nostr-relay-extension/ /Users/my-user/git-repos/lnbits/lnbits/extensions/nostrrelay +``` \ No newline at end of file diff --git a/models.py b/models.py index 071c3fe..d83f4ef 100644 --- a/models.py +++ b/models.py @@ -307,10 +307,14 @@ class NostrFilter(BaseModel): return inner_joins, where, values -class RelayJoin(BaseModel): +class BuyOrder(BaseModel): + action: str relay_id: str pubkey: str + units_to_buy = 0 + def is_valid_action(self): + return self.action in ['join', 'storage'] class NostrAccount(BaseModel): pubkey: str diff --git a/tasks.py b/tasks.py index 11dbc50..db09d7b 100644 --- a/tasks.py +++ b/tasks.py @@ -31,6 +31,11 @@ async def on_invoice_paid(payment: Payment): await invoice_paid_to_join(relay_id, pubkey) return + if payment.extra.get("action") == "storage": + storage_to_buy = payment.extra.get("storage_to_buy") + await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy) + return + async def invoice_paid_to_join(relay_id: str, pubkey: str): try: account = await get_account(relay_id, pubkey) @@ -44,5 +49,22 @@ async def invoice_paid_to_join(relay_id: str, pubkey: str): account.paid_to_join = True await update_account(relay_id, account) + except Exception as ex: + logger.warning(ex) + + +async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: int): + try: + account = await get_account(relay_id, pubkey) + if not account: + await create_account(relay_id, NostrAccount(pubkey=pubkey, storage=storage_to_buy)) + return + + if account.blocked: + return + + account.storage = storage_to_buy + await update_account(relay_id, account) + except Exception as ex: logger.warning(ex) \ No newline at end of file diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 6c08824..29207a5 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -12,7 +12,7 @@

- + Public Key: - Show Invoice - Cost to join: - - - - sats - +
+
+ Cost to join: +
+
+ + sats +
+
+ Pay to Join +
+
+ +
+
+ Storage cost: +
+
+ + sats per + + + +
+
+ +
+
+ + sats +
+
+ Buy storage space +
+
+
This is a free relay - +
- Copy invoice
@@ -66,7 +106,7 @@
@@ -163,11 +203,19 @@ return { relay: JSON.parse('{{relay | tojson | safe}}'), pubkey: '', - joinInvoice: '' + invoice: '', + unitsToBuy: 0 + } + }, + computed: { + storageCost: function () { + if (!this.relay || !this.relay.config.storageCostValue) return 0 + return this.unitsToBuy * this.relay.config.storageCostValue } }, methods: { - createJoinInvoice: async function () { + createInvoice: async function (action) { + if (!action) return if (!this.pubkey) { this.$q.notify({ timeout: 5000, @@ -177,17 +225,20 @@ return } try { + const reqData = { + action, + relay_id: this.relay.id, + pubkey: this.pubkey, + units_to_buy: this.unitsToBuy + } const {data} = await LNbits.api.request( 'PUT', - '/nostrrelay/api/v1/join', + '/nostrrelay/api/v1/pay', '', - { - relay_id: this.relay.id, - pubkey: this.pubkey - } + reqData ) console.log('### data.invoice', data.invoice) - this.joinInvoice = data.invoice + this.invoice = data.invoice } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/views_api.py b/views_api.py index d5f98c9..1260d23 100644 --- a/views_api.py +++ b/views_api.py @@ -27,7 +27,7 @@ from .crud import ( update_relay, ) from .helpers import normalize_public_key -from .models import NostrRelay, RelayJoin +from .models import BuyOrder, NostrRelay client_manager = NostrClientManager() @@ -154,9 +154,8 @@ async def api_delete_relay( ) -@nostrrelay_ext.put("/api/v1/join") -async def api_pay_to_join(data: RelayJoin): - +@nostrrelay_ext.put("/api/v1/pay") +async def api_pay_to_join(data: BuyOrder): try: pubkey = normalize_public_key(data.pubkey) relay = await get_relay_by_id(data.relay_id) @@ -166,18 +165,29 @@ async def api_pay_to_join(data: RelayJoin): detail="Relay not found", ) - if relay.is_free_to_join: + if data.action == 'join' and relay.is_free_to_join: raise ValueError("Relay is free to join") + storage_to_buy = 0 + if data.action == 'storage': + if relay.config.storage_cost_value == 0: + raise ValueError("Relay storage cost is zero. Cannot buy!") + if data.units_to_buy == 0: + raise ValueError("Must specify how much storage to buy!") + storage_to_buy = data.units_to_buy * relay.config.storage_cost_value * 1024 + if relay.config.storage_cost_unit == "MB": + storage_to_buy *= 1024 + _, payment_request = await create_invoice( wallet_id=relay.config.wallet, amount=int(relay.config.cost_to_join), memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}", extra={ "tag": "nostrrely", - "action": "join", + "action": data.action, "relay_id": relay.id, "pubkey": pubkey, + "storage_to_buy": storage_to_buy }, ) print("### payment_request", payment_request) From 44ae8086cca860dea32ef1bdab2a6c911a6e2c62 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 14 Feb 2023 09:46:33 +0200 Subject: [PATCH 009/114] feat: check `paid_to_join` and storage --- client_manager.py | 31 ++++++++++++++++++------------- models.py | 33 ++++++++++++++++++--------------- tasks.py | 1 - templates/nostrrelay/index.html | 10 +++++++++- 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/client_manager.py b/client_manager.py index 89074dd..0ee0e6f 100644 --- a/client_manager.py +++ b/client_manager.py @@ -8,6 +8,7 @@ from loguru import logger from .crud import ( create_event, delete_events, + get_account, get_config_for_all_active_relays, get_event, get_events, @@ -15,7 +16,7 @@ from .crud import ( mark_events_deleted, prune_old_events, ) -from .models import NostrEvent, NostrEventType, NostrFilter, RelaySpec +from .models import NostrAccount, NostrEvent, NostrEventType, NostrFilter, RelaySpec class NostrClientManager: @@ -264,19 +265,20 @@ class NostrClientConnection: return True, "" - async def _validate_storage(self, pubkey: str, size_bytes: int) -> Tuple[bool, str]: - if self.client_config.free_storage_value == 0: - if not self.client_config.is_paid_relay: - return False, "Cannot write event, relay is read-only" - # todo: handeld paid paid plan - return True, "Temp OK" + async def _validate_storage(self, pubkey: str, event_size_bytes: int) -> Tuple[bool, str]: + if self.client_config.is_read_only_relay: + return False, "Cannot write event, relay is read-only" + + account = await get_account(self.relay_id, pubkey) + if not account: + account = NostrAccount.null_account() + + if not account.paid_to_join and self.client_config.is_paid_relay: + return False, f"This is a paid relay: '{self.relay_id}'" stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) - if self.client_config.is_paid_relay: - # todo: handeld paid paid plan - return True, "Temp OK" - - if (stored_bytes + size_bytes) <= self.client_config.free_storage_bytes_value: + total_available_storage = account.storage + self.client_config.free_storage_bytes_value + if (stored_bytes + event_size_bytes) <= total_available_storage: return True, "" if self.client_config.full_storage_action == "block": @@ -285,7 +287,10 @@ class NostrClientConnection: f"Cannot write event, no more storage available for public key: '{pubkey}'", ) - await prune_old_events(self.relay_id, pubkey, size_bytes) + if event_size_bytes > total_available_storage: + return False, "Message is too large. Not enough storage available for it." + + await prune_old_events(self.relay_id, pubkey, event_size_bytes) return True, "" diff --git a/models.py b/models.py index d83f4ef..45f6b51 100644 --- a/models.py +++ b/models.py @@ -62,6 +62,13 @@ class StorageSpec(Spec): value *= 1024 return value +class PaymentSpec(BaseModel): + is_paid_relay = Field(False, alias="isPaidRelay") + cost_to_join = Field(0, alias="costToJoin") + + storage_cost_value = Field(0, alias="storageCostValue") + storage_cost_unit = Field("MB", alias="storageCostUnit") + class AuthorSpec(Spec): allowed_public_keys = Field([], alias="allowedPublicKeys") @@ -75,29 +82,21 @@ class AuthorSpec(Spec): # todo: check payment return p in self.allowed_public_keys - -class PaymentSpec(BaseModel): - is_paid_relay = Field(False, alias="isPaidRelay") - cost_to_join = Field(0, alias="costToJoin") - - storage_cost_value = Field(0, alias="storageCostValue") - storage_cost_unit = Field("MB", alias="storageCostUnit") - - class WalletSpec(Spec): wallet = Field("") -class RelaySpec( - FilterSpec, EventSpec, StorageSpec, AuthorSpec, PaymentSpec, WalletSpec -): - pass - - class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): + @property + def is_read_only_relay(self): + self.free_storage_value == 0 and not self.is_paid_relay + +class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec): pass + + class NostrRelay(BaseModel): id: str name: str @@ -324,6 +323,10 @@ class NostrAccount(BaseModel): allowed = False blocked = False + @classmethod + def null_account(cls) -> "NostrAccount": + return NostrAccount(pubkey="") + @classmethod def from_row(cls, row: Row) -> "NostrAccount": return cls(**dict(row)) \ No newline at end of file diff --git a/tasks.py b/tasks.py index db09d7b..c3a8322 100644 --- a/tasks.py +++ b/tasks.py @@ -4,7 +4,6 @@ import re from loguru import logger from lnbits.core.models import Payment -from lnbits.extensions.nostrrelay.models import NostrAccount from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index c001f66..77291c3 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -68,7 +68,15 @@ /> - {{props.row.id}} + + + {{props.row.id}} + Date: Tue, 14 Feb 2023 09:56:57 +0200 Subject: [PATCH 010/114] fix: missing import --- tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index c3a8322..f3f080a 100644 --- a/tasks.py +++ b/tasks.py @@ -1,14 +1,14 @@ import asyncio -import re from loguru import logger from lnbits.core.models import Payment + from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener from .crud import create_account, get_account, update_account - +from .models import NostrAccount async def wait_for_paid_invoices(): invoice_queue = asyncio.Queue() From d0c6f1392b2fdb10b24052da651b6bedeb42c3c3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 14 Feb 2023 10:27:24 +0200 Subject: [PATCH 011/114] feat: on create save domain for relay --- models.py | 2 ++ static/components/relay-details/relay-details.html | 12 ++++++++++++ views_api.py | 6 ++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/models.py b/models.py index 45f6b51..427afbc 100644 --- a/models.py +++ b/models.py @@ -87,6 +87,8 @@ class WalletSpec(Spec): class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): + domain: str = '' + @property def is_read_only_relay(self): self.free_storage_value == 0 and not self.is_paid_relay diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 42ad9e5..a712085 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -57,6 +57,18 @@
+
+
Domain:
+
+ +
+
+
diff --git a/views_api.py b/views_api.py index 1260d23..2132dc2 100644 --- a/views_api.py +++ b/views_api.py @@ -1,7 +1,8 @@ from http import HTTPStatus from typing import List, Optional -from fastapi import Depends, WebSocket +from urllib.parse import urlparse +from fastapi import Depends, WebSocket, Request from fastapi.exceptions import HTTPException from loguru import logger from pydantic.types import UUID4 @@ -48,7 +49,7 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket): @nostrrelay_ext.post("/api/v1/relay") async def api_create_relay( - data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key) + data: NostrRelay, request: Request, wallet: WalletTypeInfo = Depends(require_admin_key) ) -> NostrRelay: if len(data.id): await check_admin(UUID4(wallet.wallet.user)) @@ -56,6 +57,7 @@ async def api_create_relay( data.id = urlsafe_short_hash()[:8] try: + data.config.domain = urlparse(str(request.url)).netloc relay = await create_relay(wallet.wallet.user, data) return relay From 3648dc212c13ddbb9225b562b8ae0853c18622b5 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 14 Feb 2023 17:26:40 +0200 Subject: [PATCH 012/114] feat: partial `AUTH` support --- client_manager.py | 43 ++++++++++-- models.py | 17 ++++- .../relay-details/relay-details.html | 66 +++++++++++++++++++ .../components/relay-details/relay-details.js | 15 ++++- tasks.py | 2 +- templates/nostrrelay/index.html | 10 +-- views_api.py | 4 +- 7 files changed, 141 insertions(+), 16 deletions(-) diff --git a/client_manager.py b/client_manager.py index 0ee0e6f..d0a2c0f 100644 --- a/client_manager.py +++ b/client_manager.py @@ -5,6 +5,8 @@ from typing import Any, Awaitable, Callable, List, Optional, Tuple from fastapi import WebSocket from loguru import logger +from lnbits.helpers import urlsafe_short_hash + from .crud import ( create_event, delete_events, @@ -74,7 +76,6 @@ class NostrClientManager: if c.relay_id not in self._active_relays: await c.stop(reason=f"Relay '{c.relay_id}' is not active") return False - # todo: NIP-42: AUTH return True def _set_client_callbacks(self, client): @@ -91,13 +92,20 @@ class NostrClientConnection: self.websocket = websocket self.relay_id = relay_id self.filters: List[NostrFilter] = [] + self.authenticated = False + self.pubkey: str = None + self._auth_challenge: str = None + self._auth_challenge_created_at = 0 + + self._last_event_timestamp = 0 # in seconds + self._event_count_per_timestamp = 0 + self.broadcast_event: Optional[ Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] ] = None self.get_client_config: Optional[Callable[[], RelaySpec]] = None - self._last_event_timestamp = 0 # in seconds - self._event_count_per_timestamp = 0 + async def start(self): await self.websocket.accept() @@ -150,13 +158,21 @@ class NostrClientConnection: return await self._handle_request(data[1], NostrFilter.parse_obj(data[2])) if message_type == NostrEventType.CLOSE: self._handle_close(data[1]) + if message_type == NostrEventType.AUTH: + self._handle_auth(data[1]) return [] async def _handle_event(self, e: NostrEvent): - resp_nip20: List[Any] = ["OK", e.id] logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") + resp_nip20: List[Any] = ["OK", e.id] + if not self.authenticated and self.client_config.event_requires_auth(e.kind): + await self._send_msg(["AUTH", self._current_auth_challenge()]) + resp_nip20 += [False, "Relay requires authentication"] + await self._send_msg(resp_nip20) + return None + valid, message = await self._validate_write(e) if not valid: resp_nip20 += [valid, message] @@ -201,6 +217,9 @@ class NostrClientConnection: await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: + if not self.authenticated and self.client_config.require_auth_filter: + return [["AUTH", self._current_auth_challenge()]] + filter.subscription_id = subscription_id self._remove_filter(subscription_id) if self._can_add_filter(): @@ -227,6 +246,9 @@ class NostrClientConnection: def _handle_close(self, subscription_id: str): self._remove_filter(subscription_id) + def _handle_auth(self): + raise ValueError('Not supported') + def _can_add_filter(self) -> bool: return ( self.client_config.max_client_filters != 0 @@ -318,3 +340,16 @@ class NostrClientConnection: if created_at > (current_time + self.client_config.created_at_in_future): return False, "created_at is too much into the future" return True, "" + + def _auth_challenge_expired(self): + if self._auth_challenge_created_at == 0: + return True + current_time_seconds = round(time.time()) + chanllenge_max_age_seconds = 300 # 5 min + return (current_time_seconds - self._auth_challenge_created_at) >= chanllenge_max_age_seconds + + def _current_auth_challenge(self): + if self._auth_challenge_expired(): + self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash() + self._auth_challenge_created_at = round(time.time()) + return self._auth_challenge \ No newline at end of file diff --git a/models.py b/models.py index 427afbc..d0cb43b 100644 --- a/models.py +++ b/models.py @@ -62,6 +62,17 @@ class StorageSpec(Spec): value *= 1024 return value + +class AuthSpec(BaseModel): + require_auth_events = Field(False, alias="requireAuthEvents") + skiped_auth_events = Field([], alias="skipedAuthEvents") + require_auth_filter = Field(False, alias="requireAuthFilter") + + def event_requires_auth(self, kind: int) -> bool: + if not self.require_auth_events: + return False + return kind not in self.skiped_auth_events + class PaymentSpec(BaseModel): is_paid_relay = Field(False, alias="isPaidRelay") cost_to_join = Field(0, alias="costToJoin") @@ -93,7 +104,7 @@ class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): def is_read_only_relay(self): self.free_storage_value == 0 and not self.is_paid_relay -class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec): +class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec, AuthSpec): pass @@ -135,6 +146,7 @@ class NostrEventType(str, Enum): EVENT = "EVENT" REQ = "REQ" CLOSE = "CLOSE" + AUTH = "AUTH" class NostrEvent(BaseModel): @@ -167,6 +179,9 @@ class NostrEvent(BaseModel): def is_replaceable_event(self) -> bool: return self.kind in [0, 3] + def is_ephemeral_event(self) -> bool: + return self.kind in [22242] + def is_delete_event(self) -> bool: return self.kind == 5 diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index a712085..ba235d4 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -328,6 +328,72 @@ +
+
Require Auth :
+
+ For Filters +
+
+ For Events +
+
+ + + Require client authentication for accessing different types of + resources. + +
+
+
+
Skip Auth For Events:
+
+ +
+
+ +
+
+ + {{ e }} + + +
+
+
Full Storage Action:
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 39755ca..898d7b5 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -17,7 +17,8 @@ async function relayDetails(path) { name: '', description: '' } - } + }, + skipEventKind: 0 } }, @@ -128,6 +129,18 @@ async function relayDetails(path) { deleteBlockedPublicKey: function (pubKey) { this.relay.config.blockedPublicKeys = this.relay.config.blockedPublicKeys.filter(p => p !== pubKey) + }, + addSkipAuthForEvent: function () { + value = +this.skipEventKind + if (this.relay.config.skipedAuthEvents.indexOf(value) != -1) { + return + } + this.relay.config.skipedAuthEvents.push(value) + }, + removeSkipAuthForEvent: function (eventKind) { + value = +eventKind + this.relay.config.skipedAuthEvents = + this.relay.config.skipedAuthEvents.filter(e => e !== value) } }, diff --git a/tasks.py b/tasks.py index f3f080a..d6dff60 100644 --- a/tasks.py +++ b/tasks.py @@ -3,13 +3,13 @@ import asyncio from loguru import logger from lnbits.core.models import Payment - from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener from .crud import create_account, get_account, update_account from .models import NostrAccount + async def wait_for_paid_invoices(): invoice_queue = asyncio.Queue() register_invoice_listener(invoice_queue, get_current_extension_name()) diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index 77291c3..841193b 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -69,13 +69,9 @@ - - {{props.row.id}} + + {{props.row.id}} Date: Wed, 15 Feb 2023 10:33:56 +0200 Subject: [PATCH 013/114] refactor: extract `extract_domain` function --- client_manager.py | 40 +++++++++++++++++++++++++++++++++++----- helpers.py | 5 +++++ views_api.py | 5 ++--- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/client_manager.py b/client_manager.py index d0a2c0f..cb99b44 100644 --- a/client_manager.py +++ b/client_manager.py @@ -18,6 +18,7 @@ from .crud import ( mark_events_deleted, prune_old_events, ) +from .helpers import extract_domain from .models import NostrAccount, NostrEvent, NostrEventType, NostrFilter, RelaySpec @@ -166,6 +167,17 @@ class NostrClientConnection: async def _handle_event(self, e: NostrEvent): logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") resp_nip20: List[Any] = ["OK", e.id] + + if e.is_auth_response_event: + valid, message = self._validate_auth_event(e) + if not valid: + resp_nip20 += [valid, message] + await self._send_msg(resp_nip20) + return None + self.authenticated = True + return None + + if not self.authenticated and self.client_config.event_requires_auth(e.kind): await self._send_msg(["AUTH", self._current_auth_challenge()]) resp_nip20 += [False, "Relay requires authentication"] @@ -180,14 +192,14 @@ class NostrClientConnection: return None try: - if e.is_replaceable_event(): + if e.is_replaceable_event: await delete_events( self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey]) ) await create_event(self.relay_id, e) await self._broadcast_event(e) - if e.is_delete_event(): + if e.is_delete_event: await self._handle_delete_event(e) resp_nip20 += [True, ""] except Exception as ex: @@ -213,7 +225,7 @@ class NostrClientConnection: filter = NostrFilter(authors=[event.pubkey]) filter.ids = [t[1] for t in event.tags if t[0] == "e"] events_to_delete = await get_events(self.relay_id, filter, False) - ids = [e.id for e in events_to_delete if not e.is_delete_event()] + ids = [e.id for e in events_to_delete if not e.is_delete_event] await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: @@ -255,14 +267,32 @@ class NostrClientConnection: and len(self.filters) >= self.client_config.max_client_filters ) - async def _validate_write(self, e: NostrEvent) -> Tuple[bool, str]: + def _validate_auth_event(self, e: NostrEvent) -> Tuple[bool, str]: valid, message = self._validate_event(e) if not valid: return [valid, message] + relay_tag = e.tag_values("relay") + challenge_tag = e.tag_values("challenge") + if len(relay_tag) == 0 or len(challenge_tag) == 0: + return False, "NIP42 tags are missing" + + if self.client_config.domain != extract_domain(relay_tag[0]): + return False, "Wrong relay domain" + + if self._auth_challenge != challenge_tag[0]: + return False, "Wrong chanlange value" + + return True, "" + + async def _validate_write(self, e: NostrEvent) -> Tuple[bool, str]: + valid, message = self._validate_event(e) + if not valid: + return (valid, message) + valid, message = await self._validate_storage(e.pubkey, e.size_bytes) if not valid: - return [valid, message] + return (valid, message) return True, "" diff --git a/helpers.py b/helpers.py index bcf5c02..8e0b15d 100644 --- a/helpers.py +++ b/helpers.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + from bech32 import bech32_decode, convertbits @@ -17,3 +19,6 @@ def normalize_public_key(pubkey: str) -> str: raise ValueError("Public Key is not valid hex") int(pubkey, 16) return pubkey + +def extract_domain(url: str) -> str: + return urlparse(url).netloc \ No newline at end of file diff --git a/views_api.py b/views_api.py index df53d96..f44ca95 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,5 @@ from http import HTTPStatus from typing import List, Optional -from urllib.parse import urlparse from fastapi import Depends, Request, WebSocket from fastapi.exceptions import HTTPException @@ -27,7 +26,7 @@ from .crud import ( get_relays, update_relay, ) -from .helpers import normalize_public_key +from .helpers import extract_domain, normalize_public_key from .models import BuyOrder, NostrRelay client_manager = NostrClientManager() @@ -57,7 +56,7 @@ async def api_create_relay( data.id = urlsafe_short_hash()[:8] try: - data.config.domain = urlparse(str(request.url)).netloc + data.config.domain = extract_domain(str(request.url)) relay = await create_relay(wallet.wallet.user, data) return relay From b472e974545fcdf0e8831bb96494a27a536217b0 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 10:34:21 +0200 Subject: [PATCH 014/114] feat: handle auth - see prev commit :) --- models.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/models.py b/models.py index d0cb43b..da2c673 100644 --- a/models.py +++ b/models.py @@ -176,15 +176,22 @@ class NostrEvent(BaseModel): s = json.dumps(dict(self), separators=(",", ":"), ensure_ascii=False) return len(s.encode()) + @property def is_replaceable_event(self) -> bool: return self.kind in [0, 3] - def is_ephemeral_event(self) -> bool: - return self.kind in [22242] - + @property + def is_auth_response_event(self) -> bool: + return self.kind == 22242 + + @property def is_delete_event(self) -> bool: return self.kind == 5 + @property + def is_ephemeral_event(self) -> bool: + return self.kind in [22242] + def check_signature(self): event_id = self.event_id if self.id != event_id: @@ -207,6 +214,9 @@ class NostrEvent(BaseModel): def serialize_response(self, subscription_id): return [NostrEventType.EVENT, subscription_id, dict(self)] + def tag_values(self, tag_name: str) -> List[List[str]]: + return [t[1] for t in self.tags if t[0] == tag_name] + @classmethod def from_row(cls, row: Row) -> "NostrEvent": return cls(**dict(row)) From 366dae2082b51f659f82a72ae78e116b79d099ff Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 10:46:50 +0200 Subject: [PATCH 015/114] chore: label update --- client_manager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client_manager.py b/client_manager.py index cb99b44..ccffe7a 100644 --- a/client_manager.py +++ b/client_manager.py @@ -180,7 +180,7 @@ class NostrClientConnection: if not self.authenticated and self.client_config.event_requires_auth(e.kind): await self._send_msg(["AUTH", self._current_auth_challenge()]) - resp_nip20 += [False, "Relay requires authentication"] + resp_nip20 += [False, f"restricted: Relay requires authentication for events of kind '{e.kind}'"] await self._send_msg(resp_nip20) return None @@ -275,13 +275,13 @@ class NostrClientConnection: relay_tag = e.tag_values("relay") challenge_tag = e.tag_values("challenge") if len(relay_tag) == 0 or len(challenge_tag) == 0: - return False, "NIP42 tags are missing" + return False, "error: NIP42 tags are missing for auth event" if self.client_config.domain != extract_domain(relay_tag[0]): - return False, "Wrong relay domain" + return False, "error: wrong relay domain for auth event" if self._auth_challenge != challenge_tag[0]: - return False, "Wrong chanlange value" + return False, "error: wrong chanlange value for auth event" return True, "" From 58723a387f75a706aeb176ecd44c42efcccad976 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 10:49:36 +0200 Subject: [PATCH 016/114] chore: code format --- client_manager.py | 33 +++++++++++++++++++-------------- crud.py | 19 +++++++------------ helpers.py | 3 ++- models.py | 14 ++++++++------ tasks.py | 15 ++++++++++----- views_api.py | 10 ++++++---- 6 files changed, 52 insertions(+), 42 deletions(-) diff --git a/client_manager.py b/client_manager.py index ccffe7a..3a2cd12 100644 --- a/client_manager.py +++ b/client_manager.py @@ -106,8 +106,6 @@ class NostrClientConnection: ] = None self.get_client_config: Optional[Callable[[], RelaySpec]] = None - - async def start(self): await self.websocket.accept() while True: @@ -177,14 +175,15 @@ class NostrClientConnection: self.authenticated = True return None - if not self.authenticated and self.client_config.event_requires_auth(e.kind): await self._send_msg(["AUTH", self._current_auth_challenge()]) - resp_nip20 += [False, f"restricted: Relay requires authentication for events of kind '{e.kind}'"] + resp_nip20 += [ + False, + f"restricted: Relay requires authentication for events of kind '{e.kind}'", + ] await self._send_msg(resp_nip20) - return None + return None - valid, message = await self._validate_write(e) if not valid: resp_nip20 += [valid, message] @@ -259,7 +258,7 @@ class NostrClientConnection: self._remove_filter(subscription_id) def _handle_auth(self): - raise ValueError('Not supported') + raise ValueError("Not supported") def _can_add_filter(self) -> bool: return ( @@ -276,7 +275,7 @@ class NostrClientConnection: challenge_tag = e.tag_values("challenge") if len(relay_tag) == 0 or len(challenge_tag) == 0: return False, "error: NIP42 tags are missing for auth event" - + if self.client_config.domain != extract_domain(relay_tag[0]): return False, "error: wrong relay domain for auth event" @@ -317,10 +316,12 @@ class NostrClientConnection: return True, "" - async def _validate_storage(self, pubkey: str, event_size_bytes: int) -> Tuple[bool, str]: + async def _validate_storage( + self, pubkey: str, event_size_bytes: int + ) -> Tuple[bool, str]: if self.client_config.is_read_only_relay: return False, "Cannot write event, relay is read-only" - + account = await get_account(self.relay_id, pubkey) if not account: account = NostrAccount.null_account() @@ -329,7 +330,9 @@ class NostrClientConnection: return False, f"This is a paid relay: '{self.relay_id}'" stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) - total_available_storage = account.storage + self.client_config.free_storage_bytes_value + total_available_storage = ( + account.storage + self.client_config.free_storage_bytes_value + ) if (stored_bytes + event_size_bytes) <= total_available_storage: return True, "" @@ -375,11 +378,13 @@ class NostrClientConnection: if self._auth_challenge_created_at == 0: return True current_time_seconds = round(time.time()) - chanllenge_max_age_seconds = 300 # 5 min - return (current_time_seconds - self._auth_challenge_created_at) >= chanllenge_max_age_seconds + chanllenge_max_age_seconds = 300 # 5 min + return ( + current_time_seconds - self._auth_challenge_created_at + ) >= chanllenge_max_age_seconds def _current_auth_challenge(self): if self._auth_challenge_expired(): self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash() self._auth_challenge_created_at = round(time.time()) - return self._auth_challenge \ No newline at end of file + return self._auth_challenge diff --git a/crud.py b/crud.py index 83cb7fb..5b986c4 100644 --- a/crud.py +++ b/crud.py @@ -13,6 +13,7 @@ from .models import ( ########################## RELAYS #################### + async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.execute( """ @@ -326,9 +327,9 @@ def build_select_events_query(relay_id: str, filter: NostrFilter): return query, values - ########################## ACCOUNTS #################### + async def create_account(relay_id: str, a: NostrAccount) -> NostrAccount: await db.execute( """ @@ -357,25 +358,19 @@ async def update_account(relay_id: str, a: NostrAccount) -> NostrAccount: SET (sats, storage, paid_to_join, allowed, blocked) = (?, ?, ?, ?, ?) WHERE relay_id = ? AND pubkey = ? """, - ( - a.sats, - a.storage, - a.paid_to_join, - a.allowed, - a.blocked, - relay_id, - a.pubkey - ), + (a.sats, a.storage, a.paid_to_join, a.allowed, a.blocked, relay_id, a.pubkey), ) return a -async def get_account(relay_id: str, pubkey: str,) -> Optional[NostrAccount]: +async def get_account( + relay_id: str, + pubkey: str, +) -> Optional[NostrAccount]: row = await db.fetchone( "SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND pubkey = ?", (relay_id, pubkey), ) return NostrAccount.from_row(row) if row else None - diff --git a/helpers.py b/helpers.py index 8e0b15d..5f2b065 100644 --- a/helpers.py +++ b/helpers.py @@ -20,5 +20,6 @@ def normalize_public_key(pubkey: str) -> str: int(pubkey, 16) return pubkey + def extract_domain(url: str) -> str: - return urlparse(url).netloc \ No newline at end of file + return urlparse(url).netloc diff --git a/models.py b/models.py index da2c673..c00cd15 100644 --- a/models.py +++ b/models.py @@ -73,6 +73,7 @@ class AuthSpec(BaseModel): return False return kind not in self.skiped_auth_events + class PaymentSpec(BaseModel): is_paid_relay = Field(False, alias="isPaidRelay") cost_to_join = Field(0, alias="costToJoin") @@ -93,23 +94,23 @@ class AuthorSpec(Spec): # todo: check payment return p in self.allowed_public_keys + class WalletSpec(Spec): wallet = Field("") class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): - domain: str = '' + domain: str = "" @property def is_read_only_relay(self): self.free_storage_value == 0 and not self.is_paid_relay + class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec, AuthSpec): pass - - class NostrRelay(BaseModel): id: str name: str @@ -183,7 +184,7 @@ class NostrEvent(BaseModel): @property def is_auth_response_event(self) -> bool: return self.kind == 22242 - + @property def is_delete_event(self) -> bool: return self.kind == 5 @@ -340,7 +341,8 @@ class BuyOrder(BaseModel): units_to_buy = 0 def is_valid_action(self): - return self.action in ['join', 'storage'] + return self.action in ["join", "storage"] + class NostrAccount(BaseModel): pubkey: str @@ -356,4 +358,4 @@ class NostrAccount(BaseModel): @classmethod def from_row(cls, row: Row) -> "NostrAccount": - return cls(**dict(row)) \ No newline at end of file + return cls(**dict(row)) diff --git a/tasks.py b/tasks.py index d6dff60..88c21fc 100644 --- a/tasks.py +++ b/tasks.py @@ -35,13 +35,16 @@ async def on_invoice_paid(payment: Payment): await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy) return + async def invoice_paid_to_join(relay_id: str, pubkey: str): try: account = await get_account(relay_id, pubkey) if not account: - await create_account(relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True)) + await create_account( + relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True) + ) return - + if account.blocked or account.paid_to_join: return @@ -56,9 +59,11 @@ async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: i try: account = await get_account(relay_id, pubkey) if not account: - await create_account(relay_id, NostrAccount(pubkey=pubkey, storage=storage_to_buy)) + await create_account( + relay_id, NostrAccount(pubkey=pubkey, storage=storage_to_buy) + ) return - + if account.blocked: return @@ -66,4 +71,4 @@ async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: i await update_account(relay_id, account) except Exception as ex: - logger.warning(ex) \ No newline at end of file + logger.warning(ex) diff --git a/views_api.py b/views_api.py index f44ca95..ca45aa1 100644 --- a/views_api.py +++ b/views_api.py @@ -48,7 +48,9 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket): @nostrrelay_ext.post("/api/v1/relay") async def api_create_relay( - data: NostrRelay, request: Request, wallet: WalletTypeInfo = Depends(require_admin_key) + data: NostrRelay, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> NostrRelay: if len(data.id): await check_admin(UUID4(wallet.wallet.user)) @@ -166,11 +168,11 @@ async def api_pay_to_join(data: BuyOrder): detail="Relay not found", ) - if data.action == 'join' and relay.is_free_to_join: + if data.action == "join" and relay.is_free_to_join: raise ValueError("Relay is free to join") storage_to_buy = 0 - if data.action == 'storage': + if data.action == "storage": if relay.config.storage_cost_value == 0: raise ValueError("Relay storage cost is zero. Cannot buy!") if data.units_to_buy == 0: @@ -188,7 +190,7 @@ async def api_pay_to_join(data: BuyOrder): "action": data.action, "relay_id": relay.id, "pubkey": pubkey, - "storage_to_buy": storage_to_buy + "storage_to_buy": storage_to_buy, }, ) print("### payment_request", payment_request) From a8a2ef5e2767cb35e8c8aec5eeada6fb46146840 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 11:41:50 +0200 Subject: [PATCH 017/114] feat: force auth for particular event types --- models.py | 7 ++-- .../relay-details/relay-details.html | 40 +++++++++++++++++-- .../components/relay-details/relay-details.js | 15 ++++++- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/models.py b/models.py index c00cd15..7e5761e 100644 --- a/models.py +++ b/models.py @@ -66,12 +66,13 @@ class StorageSpec(Spec): class AuthSpec(BaseModel): require_auth_events = Field(False, alias="requireAuthEvents") skiped_auth_events = Field([], alias="skipedAuthEvents") + forced_auth_events = Field([], alias="forcedAuthEvents") require_auth_filter = Field(False, alias="requireAuthFilter") def event_requires_auth(self, kind: int) -> bool: - if not self.require_auth_events: - return False - return kind not in self.skiped_auth_events + if self.require_auth_events: + return kind not in self.skiped_auth_events + return kind in self.forced_auth_events class PaymentSpec(BaseModel): diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index ba235d4..0041c3c 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -343,7 +343,7 @@ color="secodary" class="q-ml-md q-mr-md" v-model="relay.config.requireAuthEvents" - >For EventsFor All Events
@@ -388,11 +388,43 @@ > {{ e }} -
+
+
Force Auth For Events:
+
+ +
+
+ +
+
+ + {{ e }} + +
+
Full Storage Action:
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 898d7b5..01011db 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -18,7 +18,8 @@ async function relayDetails(path) { description: '' } }, - skipEventKind: 0 + skipEventKind: 0, + forceEventKind: 0 } }, @@ -141,6 +142,18 @@ async function relayDetails(path) { value = +eventKind this.relay.config.skipedAuthEvents = this.relay.config.skipedAuthEvents.filter(e => e !== value) + }, + addForceAuthForEvent: function () { + value = +this.forceEventKind + if (this.relay.config.forcedAuthEvents.indexOf(value) != -1) { + return + } + this.relay.config.forcedAuthEvents.push(value) + }, + removeSkipAuthForEvent: function (eventKind) { + value = +eventKind + this.relay.config.forcedAuthEvents = + this.relay.config.forcedAuthEvents.filter(e => e !== value) } }, From 9aa0fdbd2cf1f0fce2a58adfd6048b6b6f084fe9 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 11:42:39 +0200 Subject: [PATCH 018/114] chore: add `42` to supported NIPs --- models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.py b/models.py index 7e5761e..f9879bb 100644 --- a/models.py +++ b/models.py @@ -138,7 +138,7 @@ class NostrRelay(BaseModel): ) -> dict: return { "contact": "https://t.me/lnbits", - "supported_nips": [1, 9, 11, 15, 20, 22], + "supported_nips": [1, 9, 11, 15, 20, 22, 42], "software": "LNbits", "version": "", } From 88d53bd73d11e971c51d0238cf397ccbf7b3b355 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 11:44:35 +0200 Subject: [PATCH 019/114] feat: respond with challenge to client `AUTH` message --- client_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client_manager.py b/client_manager.py index 3a2cd12..6750193 100644 --- a/client_manager.py +++ b/client_manager.py @@ -158,7 +158,7 @@ class NostrClientConnection: if message_type == NostrEventType.CLOSE: self._handle_close(data[1]) if message_type == NostrEventType.AUTH: - self._handle_auth(data[1]) + await self._handle_auth(data[1]) return [] @@ -257,8 +257,8 @@ class NostrClientConnection: def _handle_close(self, subscription_id: str): self._remove_filter(subscription_id) - def _handle_auth(self): - raise ValueError("Not supported") + async def _handle_auth(self): + await self._send_msg(["AUTH", self._current_auth_challenge()]) def _can_add_filter(self) -> bool: return ( From 2099a8b7bb5c4e6d79a90408ead3ea4989038042 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 11:58:15 +0200 Subject: [PATCH 020/114] refactor: allow&block clean-up --- migrations.py | 1 - .../relay-details/relay-details.html | 71 +++---------------- .../components/relay-details/relay-details.js | 2 +- 3 files changed, 11 insertions(+), 63 deletions(-) diff --git a/migrations.py b/migrations.py index 6b81a94..e69c350 100644 --- a/migrations.py +++ b/migrations.py @@ -52,7 +52,6 @@ async def m001_initial(db): CREATE TABLE nostrrelay.accounts ( relay_id TEXT NOT NULL, pubkey TEXT NOT NULL, - sats {db.big_int} DEFAULT 0, storage {db.big_int} DEFAULT 0, paid_to_join BOOLEAN DEFAULT false, allowed BOOLEAN DEFAULT false, diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 0041c3c..d999f48 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -3,8 +3,7 @@ - - + @@ -528,11 +527,11 @@
- +
-
Allowed Public Key
-
+
Public Key:
+
-
+
AddAllow
-
-
-
-
- {{p}} +
-
-
-
- - -
-
-
Blocked Public Key
-
- -
-
- AddBlock
-
-
-
- {{p}} - -
-
+
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 01011db..99f75c4 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -126,11 +126,11 @@ async function relayDetails(path) { this.relay.config.allowedPublicKeys = this.relay.config.allowedPublicKeys.filter(p => p !== pubKey) }, - deleteBlockedPublicKey: function (pubKey) { this.relay.config.blockedPublicKeys = this.relay.config.blockedPublicKeys.filter(p => p !== pubKey) }, + addSkipAuthForEvent: function () { value = +this.skipEventKind if (this.relay.config.skipedAuthEvents.indexOf(value) != -1) { From d0f38346e361e38d2303b52736c15bc67937acea Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 14:17:01 +0200 Subject: [PATCH 021/114] feat: basic account UI --- crud.py | 12 ++++ migrations.py | 1 + models.py | 10 ++- .../relay-details/relay-details.html | 4 +- .../components/relay-details/relay-details.js | 26 +++++++- tasks.py | 14 ++-- views_api.py | 65 ++++++++++++++++++- 7 files changed, 116 insertions(+), 16 deletions(-) diff --git a/crud.py b/crud.py index 5b986c4..82c1cca 100644 --- a/crud.py +++ b/crud.py @@ -374,3 +374,15 @@ async def get_account( ) return NostrAccount.from_row(row) if row else None + +async def get_accounts( + relay_id: str, + allowed = True, + blocked = False, +) -> List[NostrAccount]: + rows = await db.fetchall( + "SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND allowed = ? AND blocked = ?", + (relay_id, allowed, blocked), + ) + + return [NostrAccount.from_row(row) for row in rows] diff --git a/migrations.py b/migrations.py index e69c350..6b81a94 100644 --- a/migrations.py +++ b/migrations.py @@ -52,6 +52,7 @@ async def m001_initial(db): CREATE TABLE nostrrelay.accounts ( relay_id TEXT NOT NULL, pubkey TEXT NOT NULL, + sats {db.big_int} DEFAULT 0, storage {db.big_int} DEFAULT 0, paid_to_join BOOLEAN DEFAULT false, allowed BOOLEAN DEFAULT false, diff --git a/models.py b/models.py index f9879bb..611901a 100644 --- a/models.py +++ b/models.py @@ -344,14 +344,18 @@ class BuyOrder(BaseModel): def is_valid_action(self): return self.action in ["join", "storage"] - +class NostrPartialAccount(BaseModel): + relay_id: str + pubkey: str + allowed: Optional[bool] + blocked: Optional[bool] class NostrAccount(BaseModel): pubkey: str + allowed = False + blocked = False sats = 0 storage = 0 paid_to_join = False - allowed = False - blocked = False @classmethod def null_account(cls) -> "NostrAccount": diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index d999f48..c211444 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -3,7 +3,7 @@ - + @@ -527,7 +527,7 @@
- +
Public Key:
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 99f75c4..31662f2 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -114,9 +114,29 @@ async function relayDetails(path) { this.relay.config.wallet = this.relay.config.wallet || this.walletOptions[0].value }, - allowPublicKey: function () { - this.relay.config.allowedPublicKeys.push(this.allowedPubkey) - this.allowedPubkey = '' + allowPublicKey: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/nostrrelay/api/v1/account', + this.adminkey, + { + pubkey: this.allowedPubkey, + allowed: true + } + ) + this.relay = data + this.$emit('relay-updated', this.relay) + this.$q.notify({ + type: 'positive', + message: 'Account Updated', + timeout: 5000 + }) + this.allowedPubkey = '' + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, blockPublicKey: function () { this.relay.config.blockedPublicKeys.push(this.blockedPubkey) diff --git a/tasks.py b/tasks.py index 88c21fc..b902dbb 100644 --- a/tasks.py +++ b/tasks.py @@ -27,21 +27,21 @@ async def on_invoice_paid(payment: Payment): pubkey = payment.extra.get("pubkey") if payment.extra.get("action") == "join": - await invoice_paid_to_join(relay_id, pubkey) + await invoice_paid_to_join(relay_id, pubkey, payment.amount) return if payment.extra.get("action") == "storage": storage_to_buy = payment.extra.get("storage_to_buy") - await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy) + await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount) return -async def invoice_paid_to_join(relay_id: str, pubkey: str): +async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int): try: account = await get_account(relay_id, pubkey) if not account: await create_account( - relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True) + relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True, sats=amount) ) return @@ -49,18 +49,19 @@ async def invoice_paid_to_join(relay_id: str, pubkey: str): return account.paid_to_join = True + account.sats += amount await update_account(relay_id, account) except Exception as ex: logger.warning(ex) -async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: int): +async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: int, amount: str): try: account = await get_account(relay_id, pubkey) if not account: await create_account( - relay_id, NostrAccount(pubkey=pubkey, storage=storage_to_buy) + relay_id, NostrAccount(pubkey=pubkey, storage=storage_to_buy, sats=amount) ) return @@ -68,6 +69,7 @@ async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: i return account.storage = storage_to_buy + account.sats += amount await update_account(relay_id, account) except Exception as ex: diff --git a/views_api.py b/views_api.py index ca45aa1..3e1ae0b 100644 --- a/views_api.py +++ b/views_api.py @@ -18,16 +18,20 @@ from lnbits.helpers import urlsafe_short_hash from . import nostrrelay_ext from .client_manager import NostrClientConnection, NostrClientManager from .crud import ( + create_account, create_relay, delete_all_events, delete_relay, + get_account, + get_accounts, get_relay, get_relay_by_id, get_relays, + update_account, update_relay, ) from .helpers import extract_domain, normalize_public_key -from .models import BuyOrder, NostrRelay +from .models import BuyOrder, NostrAccount, NostrPartialAccount, NostrRelay client_manager = NostrClientManager() @@ -141,6 +145,64 @@ async def api_get_relay( return relay +@nostrrelay_ext.put("/api/v1/account") +async def api_create_or_update_account( + data: NostrPartialAccount, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> NostrAccount: + + try: + data.pubkey = normalize_public_key(data.pubkey) + account = await get_account(data.relay_id, data.pubkey) + if not account: + return await create_account(data.relay_id, NostrAccount.parse_obj(data.dict())) + + if data.blocked is not None: + account.blocked = data.blocked + if data.allowed is not None: + account.allowed = data.allowed + return await update_account(data.relay_id, account) + + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create account", + ) + + +@nostrrelay_ext.get("/api/v1/account") +async def api_get_accounts( + relay_id: str, allowed: bool, blocked: bool, wallet: WalletTypeInfo = Depends(require_invoice_key) +) -> List[NostrAccount]: + try: + # make sure the user has access to the relay + relay = await get_relay(wallet.wallet.user, relay_id) + accounts = await get_accounts(relay.id, allowed, blocked) + return accounts + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot fetch accounts", + ) + + + @nostrrelay_ext.delete("/api/v1/relay/{relay_id}") async def api_delete_relay( relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -193,7 +255,6 @@ async def api_pay_to_join(data: BuyOrder): "storage_to_buy": storage_to_buy, }, ) - print("### payment_request", payment_request) return {"invoice": payment_request} except ValueError as ex: raise HTTPException( From bc1af610db467cb0552d3b915ddb5598caec0de8 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 18:18:46 +0200 Subject: [PATCH 022/114] chore: `mypy` fixes --- client_manager.py | 8 ++++---- models.py | 2 +- tasks.py | 9 ++++++++- tests/test_clients.py | 4 ++-- tests/test_events.py | 8 ++++++-- views.py | 2 +- views_api.py | 5 +++++ 7 files changed, 27 insertions(+), 11 deletions(-) diff --git a/client_manager.py b/client_manager.py index 6750193..256b9fe 100644 --- a/client_manager.py +++ b/client_manager.py @@ -94,8 +94,8 @@ class NostrClientConnection: self.relay_id = relay_id self.filters: List[NostrFilter] = [] self.authenticated = False - self.pubkey: str = None - self._auth_challenge: str = None + self.pubkey: Optional[str] = None + self._auth_challenge: Optional[str] = None self._auth_challenge_created_at = 0 self._last_event_timestamp = 0 # in seconds @@ -158,7 +158,7 @@ class NostrClientConnection: if message_type == NostrEventType.CLOSE: self._handle_close(data[1]) if message_type == NostrEventType.AUTH: - await self._handle_auth(data[1]) + await self._handle_auth() return [] @@ -269,7 +269,7 @@ class NostrClientConnection: def _validate_auth_event(self, e: NostrEvent) -> Tuple[bool, str]: valid, message = self._validate_event(e) if not valid: - return [valid, message] + return (valid, message) relay_tag = e.tag_values("relay") challenge_tag = e.tag_values("challenge") diff --git a/models.py b/models.py index 611901a..9afcc20 100644 --- a/models.py +++ b/models.py @@ -216,7 +216,7 @@ class NostrEvent(BaseModel): def serialize_response(self, subscription_id): return [NostrEventType.EVENT, subscription_id, dict(self)] - def tag_values(self, tag_name: str) -> List[List[str]]: + def tag_values(self, tag_name: str) -> List[str]: return [t[1] for t in self.tags if t[0] == tag_name] @classmethod diff --git a/tasks.py b/tasks.py index b902dbb..521fca4 100644 --- a/tasks.py +++ b/tasks.py @@ -26,12 +26,19 @@ async def on_invoice_paid(payment: Payment): relay_id = payment.extra.get("relay_id") pubkey = payment.extra.get("pubkey") + if not relay_id or not pubkey: + logger.warning(f"Invoice extra data missing for 'relay_id' and 'pubkey'. Payment hash: {payment.payment_hash}") + return + if payment.extra.get("action") == "join": await invoice_paid_to_join(relay_id, pubkey, payment.amount) return if payment.extra.get("action") == "storage": storage_to_buy = payment.extra.get("storage_to_buy") + if not storage_to_buy: + logger.warning(f"Invoice extra data missing for 'storage_to_buy'. Payment hash: {payment.payment_hash}") + return await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount) return @@ -56,7 +63,7 @@ async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int): logger.warning(ex) -async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: int, amount: str): +async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: int, amount: int): try: account = await get_account(relay_id, pubkey) if not account: diff --git a/tests/test_clients.py b/tests/test_clients.py index 14c6346..ec493d4 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,11 +6,11 @@ import pytest from fastapi import WebSocket from loguru import logger -from lnbits.extensions.nostrrelay.client_manager import ( +from lnbits.extensions.nostrrelay.client_manager import ( # type: ignore NostrClientConnection, NostrClientManager, ) -from lnbits.extensions.nostrrelay.models import RelaySpec +from lnbits.extensions.nostrrelay.models import RelaySpec # type: ignore from .helpers import get_fixtures diff --git a/tests/test_events.py b/tests/test_events.py index 0e6c296..6af5497 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -5,8 +5,12 @@ import pytest from loguru import logger from pydantic import BaseModel -from lnbits.extensions.nostrrelay.crud import create_event, get_event, get_events -from lnbits.extensions.nostrrelay.models import NostrEvent, NostrFilter +from lnbits.extensions.nostrrelay.crud import ( # type: ignore + create_event, + get_event, + get_events, +) +from lnbits.extensions.nostrrelay.models import NostrEvent, NostrFilter # type: ignore from .helpers import get_fixtures diff --git a/views.py b/views.py index 0dedab6..7d11b17 100644 --- a/views.py +++ b/views.py @@ -7,9 +7,9 @@ from starlette.responses import HTMLResponse, JSONResponse from lnbits.core.models import User from lnbits.decorators import check_user_exists -from lnbits.extensions.nostrrelay.crud import get_public_relay from . import nostrrelay_ext, nostrrelay_renderer +from .crud import get_public_relay templates = Jinja2Templates(directory="templates") diff --git a/views_api.py b/views_api.py index 3e1ae0b..e5017aa 100644 --- a/views_api.py +++ b/views_api.py @@ -185,6 +185,11 @@ async def api_get_accounts( try: # make sure the user has access to the relay relay = await get_relay(wallet.wallet.user, relay_id) + if not relay: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Relay not found", + ) accounts = await get_accounts(relay.id, allowed, blocked) return accounts except ValueError as ex: From d8ca9f830a26067018c268986aebe7b4b7fde99e Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Feb 2023 18:19:00 +0200 Subject: [PATCH 023/114] chore: code format --- .../relay-details/relay-details.html | 68 +++++++++---------- .../components/relay-details/relay-details.js | 1 - 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index c211444..399e130 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -389,41 +389,38 @@
-
-
Force Auth For Events:
-
- +
+
Force Auth For Events:
+
+ +
+
+ +
+
+ + {{ e }} + +
-
- -
-
- - {{ e }} - -
-
Full Storage Action:
@@ -530,7 +527,7 @@
-
Public Key:
+
Public Key:
-
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 31662f2..861154b 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -136,7 +136,6 @@ async function relayDetails(path) { } catch (error) { LNbits.utils.notifyApiError(error) } - }, blockPublicKey: function () { this.relay.config.blockedPublicKeys.push(this.blockedPubkey) From 2c5dfbbf92ed6f0a23055f5003514d1bbe27b9bf Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 12:33:16 +0200 Subject: [PATCH 024/114] feat: add basic block/allow actions --- .../relay-details/relay-details.html | 6 ++--- .../components/relay-details/relay-details.js | 27 ++++++++++--------- views_api.py | 4 ++- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 399e130..92aec13 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -532,7 +532,7 @@
@@ -541,7 +541,7 @@ unelevated color="green" class="float-right" - @click="allowPublicKey()" + @click="allowPublicKey(true)" >Allow
@@ -550,7 +550,7 @@ unelevated color="pink" class="float-right" - @click="blockPublicKey()" + @click="blockPublicKey(true)" >Block
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 861154b..1b47cb2 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -9,8 +9,7 @@ async function relayDetails(path) { return { tab: 'info', relay: null, - blockedPubkey: '', - allowedPubkey: '', + accountPubkey: '', formDialogItem: { show: false, data: { @@ -114,33 +113,35 @@ async function relayDetails(path) { this.relay.config.wallet = this.relay.config.wallet || this.walletOptions[0].value }, - allowPublicKey: async function () { + allowPublicKey: async function (allowed) { + await this.updatePublicKey({allowed}) + }, + blockPublicKey: async function (blocked = true) { + await this.updatePublicKey({blocked}) + }, + updatePublicKey: async function (ops) { try { - const {data} = await LNbits.api.request( + await LNbits.api.request( 'PUT', '/nostrrelay/api/v1/account', this.adminkey, { - pubkey: this.allowedPubkey, - allowed: true + relay_id: this.relay.id, + pubkey: this.accountPubkey, + allowed: ops.allowed, + blocked: ops.blocked } ) - this.relay = data - this.$emit('relay-updated', this.relay) this.$q.notify({ type: 'positive', message: 'Account Updated', timeout: 5000 }) - this.allowedPubkey = '' + this.accountPubkey = '' } catch (error) { LNbits.utils.notifyApiError(error) } }, - blockPublicKey: function () { - this.relay.config.blockedPublicKeys.push(this.blockedPubkey) - this.blockedPubkey = '' - }, deleteAllowedPublicKey: function (pubKey) { this.relay.config.allowedPublicKeys = this.relay.config.allowedPublicKeys.filter(p => p !== pubKey) diff --git a/views_api.py b/views_api.py index e5017aa..8eb6965 100644 --- a/views_api.py +++ b/views_api.py @@ -155,12 +155,14 @@ async def api_create_or_update_account( data.pubkey = normalize_public_key(data.pubkey) account = await get_account(data.relay_id, data.pubkey) if not account: - return await create_account(data.relay_id, NostrAccount.parse_obj(data.dict())) + account = NostrAccount(pubkey=data.pubkey, blocked = data.blocked or False, allowed = data.allowed or False) + return await create_account(data.relay_id, account) if data.blocked is not None: account.blocked = data.blocked if data.allowed is not None: account.allowed = data.allowed + return await update_account(data.relay_id, account) except ValueError as ex: From 5a984bddcd188fc2df48a9db5f357f72bf3f877b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 14:17:21 +0200 Subject: [PATCH 025/114] feat: mage block/allow accounts --- client_manager.py | 14 +-- models.py | 19 +-- .../relay-details/relay-details.html | 116 ++++++++++++++---- .../components/relay-details/relay-details.js | 95 ++++++++++++-- views_api.py | 2 +- 5 files changed, 188 insertions(+), 58 deletions(-) diff --git a/client_manager.py b/client_manager.py index 256b9fe..b8f61ab 100644 --- a/client_manager.py +++ b/client_manager.py @@ -299,12 +299,6 @@ class NostrClientConnection: if self._exceeded_max_events_per_second(): return False, f"Exceeded max events per second limit'!" - if not self.client_config.is_author_allowed(e.pubkey): - return ( - False, - f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!", - ) - try: e.check_signature() except ValueError: @@ -326,7 +320,13 @@ class NostrClientConnection: if not account: account = NostrAccount.null_account() - if not account.paid_to_join and self.client_config.is_paid_relay: + if account.blocked: + return ( + False, + f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!", + ) + + if not account.can_join and self.client_config.is_paid_relay: return False, f"This is a paid relay: '{self.relay_id}'" stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) diff --git a/models.py b/models.py index 9afcc20..d482f19 100644 --- a/models.py +++ b/models.py @@ -83,18 +83,6 @@ class PaymentSpec(BaseModel): storage_cost_unit = Field("MB", alias="storageCostUnit") -class AuthorSpec(Spec): - allowed_public_keys = Field([], alias="allowedPublicKeys") - blocked_public_keys = Field([], alias="blockedPublicKeys") - - def is_author_allowed(self, p: str) -> bool: - if p in self.blocked_public_keys: - return False - if len(self.allowed_public_keys) == 0: - return True - # todo: check payment - return p in self.allowed_public_keys - class WalletSpec(Spec): wallet = Field("") @@ -108,7 +96,7 @@ class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): self.free_storage_value == 0 and not self.is_paid_relay -class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec, AuthSpec): +class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec): pass @@ -357,6 +345,11 @@ class NostrAccount(BaseModel): storage = 0 paid_to_join = False + @property + def can_join(self): + """If an account is explicitly allowed then it does not need to pay""" + return self.paid_to_join or self.allowed + @classmethod def null_account(cls) -> "NostrAccount": return NostrAccount(pubkey="") diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 92aec13..96df833 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -526,33 +526,101 @@
+ +
+
Public Key:
+
+ +
+
+ Allow +
+
+ Block +
+
+
+
+
-
Public Key:
-
- Filter:
+
+ Show Allowed Account + + Show Blocked Accounts +
+
+ +
+
+ -
-
- Allow -
-
- Block + +
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 1b47cb2..193b222 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -9,6 +9,7 @@ async function relayDetails(path) { return { tab: 'info', relay: null, + accounts: [], accountPubkey: '', formDialogItem: { show: false, @@ -17,6 +18,52 @@ async function relayDetails(path) { description: '' } }, + accountsFilter: '', + showBlockedAccounts: true, + showAllowedAccounts: false, + accountsTable: { + columns: [ + { + name: 'pubkey', + align: 'left', + label: 'Public Key', + field: 'pubkey' + }, + { + name: 'allowed', + align: 'left', + label: 'Allowed', + field: 'allowed' + }, + { + name: 'blocked', + align: 'left', + label: 'Blocked', + field: 'blocked' + }, + { + name: 'paid_to_join', + align: 'left', + label: 'Paid to join', + field: 'paid_to_join' + }, + { + name: 'sats', + align: 'left', + label: 'Spent Sats', + field: 'sats' + }, + { + name: 'storage', + align: 'left', + label: 'Storage', + field: 'storage' + } + ], + pagination: { + rowsPerPage: 10 + } + }, skipEventKind: 0, forceEventKind: 0 } @@ -113,11 +160,39 @@ async function relayDetails(path) { this.relay.config.wallet = this.relay.config.wallet || this.walletOptions[0].value }, - allowPublicKey: async function (allowed) { - await this.updatePublicKey({allowed}) + getAccounts: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + `/nostrrelay/api/v1/account?relay_id=${this.relay.id}&allowed=${this.showAllowedAccounts}&blocked=${this.showBlockedAccounts}`, + this.inkey + ) + this.accounts = data + + console.log('### this.accounts', this.accounts) + } catch (error) { + LNbits.utils.notifyApiError(error) + } }, - blockPublicKey: async function (blocked = true) { - await this.updatePublicKey({blocked}) + allowPublicKey: async function (pubkey, allowed) { + await this.updatePublicKey({pubkey, allowed}) + }, + blockPublicKey: async function (pubkey, blocked = true) { + await this.updatePublicKey({pubkey, blocked}) + }, + togglePublicKey: async function (account, action) { + if (action === 'allow') { + await this.updatePublicKey({ + pubkey: account.pubkey, + allowed: account.allowed + }) + } + if (action === 'block') { + await this.updatePublicKey({ + pubkey: account.pubkey, + blocked: account.blocked + }) + } }, updatePublicKey: async function (ops) { try { @@ -127,7 +202,7 @@ async function relayDetails(path) { this.adminkey, { relay_id: this.relay.id, - pubkey: this.accountPubkey, + pubkey: ops.pubkey, allowed: ops.allowed, blocked: ops.blocked } @@ -138,18 +213,11 @@ async function relayDetails(path) { timeout: 5000 }) this.accountPubkey = '' + await this.getAccounts() } catch (error) { LNbits.utils.notifyApiError(error) } }, - deleteAllowedPublicKey: function (pubKey) { - this.relay.config.allowedPublicKeys = - this.relay.config.allowedPublicKeys.filter(p => p !== pubKey) - }, - deleteBlockedPublicKey: function (pubKey) { - this.relay.config.blockedPublicKeys = - this.relay.config.blockedPublicKeys.filter(p => p !== pubKey) - }, addSkipAuthForEvent: function () { value = +this.skipEventKind @@ -179,6 +247,7 @@ async function relayDetails(path) { created: async function () { await this.getRelay() + await this.getAccounts() } }) } diff --git a/views_api.py b/views_api.py index 8eb6965..94bb1df 100644 --- a/views_api.py +++ b/views_api.py @@ -182,7 +182,7 @@ async def api_create_or_update_account( @nostrrelay_ext.get("/api/v1/account") async def api_get_accounts( - relay_id: str, allowed: bool, blocked: bool, wallet: WalletTypeInfo = Depends(require_invoice_key) + relay_id: str, allowed = False, blocked = True, wallet: WalletTypeInfo = Depends(require_invoice_key) ) -> List[NostrAccount]: try: # make sure the user has access to the relay From aed098499ae13fdf6e7d583294608845d8d5a79d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 14:19:21 +0200 Subject: [PATCH 026/114] chore: code format --- client_manager.py | 6 +++--- crud.py | 5 +++-- models.py | 4 +++- tasks.py | 15 +++++++++++---- views_api.py | 16 +++++++++++----- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/client_manager.py b/client_manager.py index b8f61ab..dcc2c3e 100644 --- a/client_manager.py +++ b/client_manager.py @@ -322,9 +322,9 @@ class NostrClientConnection: if account.blocked: return ( - False, - f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!", - ) + False, + f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!", + ) if not account.can_join and self.client_config.is_paid_relay: return False, f"This is a paid relay: '{self.relay_id}'" diff --git a/crud.py b/crud.py index 82c1cca..d6aad4c 100644 --- a/crud.py +++ b/crud.py @@ -375,10 +375,11 @@ async def get_account( return NostrAccount.from_row(row) if row else None + async def get_accounts( relay_id: str, - allowed = True, - blocked = False, + allowed=True, + blocked=False, ) -> List[NostrAccount]: rows = await db.fetchall( "SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND allowed = ? AND blocked = ?", diff --git a/models.py b/models.py index d482f19..7eb5dd2 100644 --- a/models.py +++ b/models.py @@ -83,7 +83,6 @@ class PaymentSpec(BaseModel): storage_cost_unit = Field("MB", alias="storageCostUnit") - class WalletSpec(Spec): wallet = Field("") @@ -332,11 +331,14 @@ class BuyOrder(BaseModel): def is_valid_action(self): return self.action in ["join", "storage"] + class NostrPartialAccount(BaseModel): relay_id: str pubkey: str allowed: Optional[bool] blocked: Optional[bool] + + class NostrAccount(BaseModel): pubkey: str allowed = False diff --git a/tasks.py b/tasks.py index 521fca4..87b8eed 100644 --- a/tasks.py +++ b/tasks.py @@ -27,7 +27,9 @@ async def on_invoice_paid(payment: Payment): pubkey = payment.extra.get("pubkey") if not relay_id or not pubkey: - logger.warning(f"Invoice extra data missing for 'relay_id' and 'pubkey'. Payment hash: {payment.payment_hash}") + logger.warning( + f"Invoice extra data missing for 'relay_id' and 'pubkey'. Payment hash: {payment.payment_hash}" + ) return if payment.extra.get("action") == "join": @@ -37,7 +39,9 @@ async def on_invoice_paid(payment: Payment): if payment.extra.get("action") == "storage": storage_to_buy = payment.extra.get("storage_to_buy") if not storage_to_buy: - logger.warning(f"Invoice extra data missing for 'storage_to_buy'. Payment hash: {payment.payment_hash}") + logger.warning( + f"Invoice extra data missing for 'storage_to_buy'. Payment hash: {payment.payment_hash}" + ) return await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount) return @@ -63,12 +67,15 @@ async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int): logger.warning(ex) -async def invoice_paid_for_storage(relay_id: str, pubkey: str, storage_to_buy: int, amount: int): +async def invoice_paid_for_storage( + relay_id: str, pubkey: str, storage_to_buy: int, amount: int +): try: account = await get_account(relay_id, pubkey) if not account: await create_account( - relay_id, NostrAccount(pubkey=pubkey, storage=storage_to_buy, sats=amount) + relay_id, + NostrAccount(pubkey=pubkey, storage=storage_to_buy, sats=amount), ) return diff --git a/views_api.py b/views_api.py index 94bb1df..1d1c843 100644 --- a/views_api.py +++ b/views_api.py @@ -155,15 +155,19 @@ async def api_create_or_update_account( data.pubkey = normalize_public_key(data.pubkey) account = await get_account(data.relay_id, data.pubkey) if not account: - account = NostrAccount(pubkey=data.pubkey, blocked = data.blocked or False, allowed = data.allowed or False) + account = NostrAccount( + pubkey=data.pubkey, + blocked=data.blocked or False, + allowed=data.allowed or False, + ) return await create_account(data.relay_id, account) if data.blocked is not None: account.blocked = data.blocked if data.allowed is not None: account.allowed = data.allowed - - return await update_account(data.relay_id, account) + + return await update_account(data.relay_id, account) except ValueError as ex: raise HTTPException( @@ -182,7 +186,10 @@ async def api_create_or_update_account( @nostrrelay_ext.get("/api/v1/account") async def api_get_accounts( - relay_id: str, allowed = False, blocked = True, wallet: WalletTypeInfo = Depends(require_invoice_key) + relay_id: str, + allowed=False, + blocked=True, + wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> List[NostrAccount]: try: # make sure the user has access to the relay @@ -209,7 +216,6 @@ async def api_get_accounts( ) - @nostrrelay_ext.delete("/api/v1/relay/{relay_id}") async def api_delete_relay( relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) From 6e67443ea4dedd8b1b6a85d8d59e3020bd73bdf1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 14:23:42 +0200 Subject: [PATCH 027/114] feat: small UI adjustments --- .../relay-details/relay-details.html | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 96df833..92b5f15 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -526,7 +526,7 @@
-
Public Key:
@@ -559,30 +559,32 @@
- -
-
Filter:
-
- Show Allowed Account - - Show Blocked Accounts -
-
- + +
+
Filter:
+
+ Show Allowed Account + + Show Blocked Accounts +
+
+
Date: Thu, 16 Feb 2023 14:37:31 +0200 Subject: [PATCH 028/114] feat: change rate limit to per `hour` instead of per `second` --- client_manager.py | 14 +++++++------- models.py | 2 +- static/components/relay-details/relay-details.html | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client_manager.py b/client_manager.py index dcc2c3e..77ef64e 100644 --- a/client_manager.py +++ b/client_manager.py @@ -98,7 +98,7 @@ class NostrClientConnection: self._auth_challenge: Optional[str] = None self._auth_challenge_created_at = 0 - self._last_event_timestamp = 0 # in seconds + self._last_event_timestamp = 0 # in hours self._event_count_per_timestamp = 0 self.broadcast_event: Optional[ @@ -296,8 +296,8 @@ class NostrClientConnection: return True, "" def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]: - if self._exceeded_max_events_per_second(): - return False, f"Exceeded max events per second limit'!" + if self._exceeded_max_events_per_hour(): + return False, f"Exceeded max events per hour limit'!" try: e.check_signature() @@ -349,11 +349,11 @@ class NostrClientConnection: return True, "" - def _exceeded_max_events_per_second(self) -> bool: - if self.client_config.max_events_per_second == 0: + def _exceeded_max_events_per_hour(self) -> bool: + if self.client_config.max_events_per_hour == 0: return False - current_time = round(time.time()) + current_time = round(time.time() / 3600) if self._last_event_timestamp == current_time: self._event_count_per_timestamp += 1 else: @@ -361,7 +361,7 @@ class NostrClientConnection: self._event_count_per_timestamp = 0 return ( - self._event_count_per_timestamp > self.client_config.max_events_per_second + self._event_count_per_timestamp > self.client_config.max_events_per_hour ) def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]: diff --git a/models.py b/models.py index 7eb5dd2..60dbb19 100644 --- a/models.py +++ b/models.py @@ -19,7 +19,7 @@ class FilterSpec(Spec): class EventSpec(Spec): - max_events_per_second = Field(0, alias="maxEventsPerSecond") + max_events_per_hour = Field(0, alias="maxEventsPerHour") created_at_days_past = Field(0, alias="createdAtDaysPast") created_at_hours_past = Field(0, alias="createdAtHoursPast") diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 92b5f15..2deeeb5 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -497,12 +497,12 @@
-
Max events per second:
+
Max events per hour:
@@ -515,7 +515,7 @@ No Limit From 1099fe7e87e298ed8f356107b463f98faafa9df7 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 14:50:40 +0200 Subject: [PATCH 029/114] doc: supported nips 1 --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 42678f1..c739a69 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,20 @@ ## One click and spin up your own Nostr relay. Share with the world, or use privately. -A simple UI wrapper for the great python relay library nostr_relay. - -UI for diagnostics and management (key alow/ban lists, rate limiting) coming soon! ### Usage +Install this extension into your LNbits instance. +.... -1. Enable extension -2. Enable relay +## Supported NIPs + - [x] NIP-01: Basic protocol flow + - [x] NIP-02: Contact List and Petnames + - `kind: 3`: delete past contact lists as soon as the relay receives a new one ### Development Create Symbolic Link: ``` ln -s /Users/my-user/git-repos/nostr-relay-extension/ /Users/my-user/git-repos/lnbits/lnbits/extensions/nostrrelay -``` \ No newline at end of file +``` From 1a300d4528ffb3b69a021275194d1e57581c0190 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 16:30:30 +0200 Subject: [PATCH 030/114] doc: nore NIP updates --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c739a69..17e35e9 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,14 @@ Install this extension into your LNbits instance. ## Supported NIPs - - [x] NIP-01: Basic protocol flow - - [x] NIP-02: Contact List and Petnames + - [x] **NIP-01**: Basic protocol flow + - [x] **NIP-02**: Contact List and Petnames - `kind: 3`: delete past contact lists as soon as the relay receives a new one + - [ ] **NIP-04**: Encrypted Direct Message + - todo: if auth: do not broadcast, send only to the intended target + - [x] **NIP-09**: Event Deletion + - [x] **NIP-11**: Relay Information Document + - >**Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) ### Development From 6130de398701398452a6c7a3952c65983dade575 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 16:36:02 +0200 Subject: [PATCH 031/114] doc: nip 15 & 16 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 17e35e9..f68d533 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ Install this extension into your LNbits instance. - [x] **NIP-09**: Event Deletion - [x] **NIP-11**: Relay Information Document - >**Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) + - [ ] **NIP-12**: Generic Tag Queries + - todo + - [x] **NIP-15**: End of Stored Events Notice + - [ ] **NIP-16**: Event Treatment + - [x] Regular Events + - [ ] Replaceable Events + - [ ] Ephemeral Events + ### Development From a120394304e888e6b0c41c79716ce33920d41a49 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 16:56:04 +0200 Subject: [PATCH 032/114] doc: more nips --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f68d533..c8bcd8b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,21 @@ Install this extension into your LNbits instance. - [x] Regular Events - [ ] Replaceable Events - [ ] Ephemeral Events - + - [x] **NIP-20**: Command Results + - todo: use correct prefixes + - [x] **NIP-22**: Event created_at Limits + - [ ] **NIP-26**: Delegated Event Signing + - not planned + - [x] **NIP-28** Public Chat + - `kind: 41`: handled similar to `kind 0` metadata events + - [ ] **NIP-33**: Parameterized Replaceable Events + - todo + - [ ] **NIP-40**: Expiration Timestamp + - todo + - [x] **NIP-42**: Authentication of clients to relays + - todo: use correct prefix + - [ ] **NIP-50**: Search Capability + - todo ### Development From 7d98bc1debbc518a6a2b64169263485fdd1d4b1d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 16:56:40 +0200 Subject: [PATCH 033/114] feat: add event kind `41` to replaceable events list --- models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.py b/models.py index 60dbb19..7f29bf2 100644 --- a/models.py +++ b/models.py @@ -167,7 +167,7 @@ class NostrEvent(BaseModel): @property def is_replaceable_event(self) -> bool: - return self.kind in [0, 3] + return self.kind in [0, 3, 41] @property def is_auth_response_event(self) -> bool: From 94730ba46415923546d7c61b158c55aa04c71242 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 17:04:23 +0200 Subject: [PATCH 034/114] feat: store `pubkey` when authenticated --- client_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client_manager.py b/client_manager.py index 77ef64e..2aeae89 100644 --- a/client_manager.py +++ b/client_manager.py @@ -173,6 +173,7 @@ class NostrClientConnection: await self._send_msg(resp_nip20) return None self.authenticated = True + self.pubkey = e.pubkey return None if not self.authenticated and self.client_config.event_requires_auth(e.kind): From 2c0bcce8c73b63fbcea1e06949b4658537440a01 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 17:54:11 +0200 Subject: [PATCH 035/114] fix: cal correct action --- client_manager.py | 22 ++++++++++++++----- .../components/relay-details/relay-details.js | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/client_manager.py b/client_manager.py index 2aeae89..84f39ca 100644 --- a/client_manager.py +++ b/client_manager.py @@ -93,8 +93,7 @@ class NostrClientConnection: self.websocket = websocket self.relay_id = relay_id self.filters: List[NostrFilter] = [] - self.authenticated = False - self.pubkey: Optional[str] = None + self.pubkey: Optional[str] = None # set if authenticated self._auth_challenge: Optional[str] = None self._auth_challenge_created_at = 0 @@ -132,6 +131,9 @@ class NostrClientConnection: pass async def notify_event(self, event: NostrEvent) -> bool: + if self._is_direct_message_for_other(event): + return False + for filter in self.filters: if filter.matches(event): resp = event.serialize_response(filter.subscription_id) @@ -139,6 +141,17 @@ class NostrClientConnection: return True return False + def _is_direct_message_for_other(self, event: NostrEvent) -> bool: + if not event.is_direct_message: + return False + if not self.client_config.event_requires_auth(event.kind): + return False + if not self.pubkey: + return True + if event.has_tag_value("p", self.pubkey): + return False + return True + async def _broadcast_event(self, e: NostrEvent): if self.broadcast_event: await self.broadcast_event(self, e) @@ -172,11 +185,10 @@ class NostrClientConnection: resp_nip20 += [valid, message] await self._send_msg(resp_nip20) return None - self.authenticated = True self.pubkey = e.pubkey return None - if not self.authenticated and self.client_config.event_requires_auth(e.kind): + if not self.pubkey and self.client_config.event_requires_auth(e.kind): await self._send_msg(["AUTH", self._current_auth_challenge()]) resp_nip20 += [ False, @@ -229,7 +241,7 @@ class NostrClientConnection: await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: - if not self.authenticated and self.client_config.require_auth_filter: + if not self.pubkey and self.client_config.require_auth_filter: return [["AUTH", self._current_auth_challenge()]] filter.subscription_id = subscription_id diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 193b222..3c8111f 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -238,7 +238,7 @@ async function relayDetails(path) { } this.relay.config.forcedAuthEvents.push(value) }, - removeSkipAuthForEvent: function (eventKind) { + removeForceAuthForEvent: function (eventKind) { value = +eventKind this.relay.config.forcedAuthEvents = this.relay.config.forcedAuthEvents.filter(e => e !== value) From ba191cabbce5e741fa72a0e9213f76681d52e2d2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 17:54:47 +0200 Subject: [PATCH 036/114] feat: do not broadcast direct messages in AUTH mode --- models.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/models.py b/models.py index 7f29bf2..3953010 100644 --- a/models.py +++ b/models.py @@ -173,6 +173,10 @@ class NostrEvent(BaseModel): def is_auth_response_event(self) -> bool: return self.kind == 22242 + @property + def is_direct_message(self) -> bool: + return self.kind == 4 + @property def is_delete_event(self) -> bool: return self.kind == 5 @@ -206,6 +210,12 @@ class NostrEvent(BaseModel): def tag_values(self, tag_name: str) -> List[str]: return [t[1] for t in self.tags if t[0] == tag_name] + def has_tag_value(self, tag_name: str, tag_value: str) -> bool: + return tag_value in self.tag_values(tag_name) + + def is_direct_message_for_pubkey(self, pubkey: str) -> bool: + return self.is_direct_message and self.has_tag_value("p", pubkey) + @classmethod def from_row(cls, row: Row) -> "NostrEvent": return cls(**dict(row)) From b5f7aa0c785d616dbeb8bcac4c5983377e53608b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 16 Feb 2023 18:09:48 +0200 Subject: [PATCH 037/114] doc: updated NIP 04 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c8bcd8b..8570d30 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Install this extension into your LNbits instance. - [x] **NIP-01**: Basic protocol flow - [x] **NIP-02**: Contact List and Petnames - `kind: 3`: delete past contact lists as soon as the relay receives a new one - - [ ] **NIP-04**: Encrypted Direct Message - - todo: if auth: do not broadcast, send only to the intended target + - [x] **NIP-04**: Encrypted Direct Message + - if `AUTH` enabled: send only to the intended target - [x] **NIP-09**: Event Deletion - [x] **NIP-11**: Relay Information Document - >**Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) From a1d7c474b049bf706c6a931b98df32afe76c83a6 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 09:38:49 +0200 Subject: [PATCH 038/114] feat: differentiate between publisher and author --- client_manager.py | 12 +++++++++--- crud.py | 13 ++++++++----- migrations.py | 1 + 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/client_manager.py b/client_manager.py index 84f39ca..2b4843e 100644 --- a/client_manager.py +++ b/client_manager.py @@ -142,6 +142,10 @@ class NostrClientConnection: return False def _is_direct_message_for_other(self, event: NostrEvent) -> bool: + """ + Direct messages are not inteded to be boradcast (even if encrypted). + If the server requires AUTH for kind '4' then direct message will be sent only to the intended client. + """ if not event.is_direct_message: return False if not self.client_config.event_requires_auth(event.kind): @@ -208,7 +212,7 @@ class NostrClientConnection: await delete_events( self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey]) ) - await create_event(self.relay_id, e) + await create_event(self.relay_id, e, self.pubkey) await self._broadcast_event(e) if e.is_delete_event: @@ -257,6 +261,7 @@ class NostrClientConnection: filter.enforce_limit(self.client_config.limit_per_filter) self.filters.append(filter) events = await get_events(self.relay_id, filter) + events = [e for e in events if not self._is_direct_message_for_other(e)] serialized_events = [ event.serialize_response(subscription_id) for event in events ] @@ -290,7 +295,7 @@ class NostrClientConnection: return False, "error: NIP42 tags are missing for auth event" if self.client_config.domain != extract_domain(relay_tag[0]): - return False, "error: wrong relay domain for auth event" + return False, "error: wrong relay domain for auth event" if self._auth_challenge != challenge_tag[0]: return False, "error: wrong chanlange value for auth event" @@ -302,7 +307,8 @@ class NostrClientConnection: if not valid: return (valid, message) - valid, message = await self._validate_storage(e.pubkey, e.size_bytes) + publisher_pubkey = self.pubkey if self.pubkey else e.pubkey + valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes) if not valid: return (valid, message) diff --git a/crud.py b/crud.py index d6aad4c..746010e 100644 --- a/crud.py +++ b/crud.py @@ -132,11 +132,13 @@ async def delete_relay(user_id: str, relay_id: str): ########################## EVENTS #################### -async def create_event(relay_id: str, e: NostrEvent): +async def create_event(relay_id: str, e: NostrEvent, publisher: Optional[str]): + publisher = publisher if publisher else e.pubkey await db.execute( """ INSERT INTO nostrrelay.events ( relay_id, + publisher, id, pubkey, created_at, @@ -145,10 +147,11 @@ async def create_event(relay_id: str, e: NostrEvent): sig, size ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( relay_id, + publisher, e.id, e.pubkey, e.created_at, @@ -199,14 +202,14 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: return event -async def get_storage_for_public_key(relay_id: str, pubkey: str) -> int: +async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> int: """Returns the storage space in bytes for all the events of a public key. Deleted events are also counted""" row = await db.fetchone( - "SELECT SUM(size) as sum FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ? GROUP BY pubkey", + "SELECT SUM(size) as sum FROM nostrrelay.events WHERE relay_id = ? AND publisher = ? GROUP BY publisher", ( relay_id, - pubkey, + publisher_pubkey, ), ) if not row: diff --git a/migrations.py b/migrations.py index 6b81a94..c76b22a 100644 --- a/migrations.py +++ b/migrations.py @@ -23,6 +23,7 @@ async def m001_initial(db): CREATE TABLE nostrrelay.events ( relay_id TEXT NOT NULL, deleted BOOLEAN DEFAULT false, + publisher TEXT NOT NULL, id TEXT NOT NULL, pubkey TEXT NOT NULL, created_at {db.big_int} NOT NULL, From 5c0209b6c026f13ccf909e87b72495e338678a23 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 09:55:23 +0200 Subject: [PATCH 039/114] feat: finish NIP16 --- client_manager.py | 8 ++++++-- models.py | 9 +++++++-- tests/test_events.py | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client_manager.py b/client_manager.py index 2b4843e..775d3dd 100644 --- a/client_manager.py +++ b/client_manager.py @@ -210,9 +210,10 @@ class NostrClientConnection: try: if e.is_replaceable_event: await delete_events( - self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey]) + self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at) ) - await create_event(self.relay_id, e, self.pubkey) + if not e.is_ephemeral_event: + await create_event(self.relay_id, e, self.pubkey) await self._broadcast_event(e) if e.is_delete_event: @@ -307,6 +308,9 @@ class NostrClientConnection: if not valid: return (valid, message) + if e.is_ephemeral_event: + return True, "" + publisher_pubkey = self.pubkey if self.pubkey else e.pubkey valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes) if not valid: diff --git a/models.py b/models.py index 3953010..f59b500 100644 --- a/models.py +++ b/models.py @@ -167,7 +167,7 @@ class NostrEvent(BaseModel): @property def is_replaceable_event(self) -> bool: - return self.kind in [0, 3, 41] + return self.kind in [0, 3, 41] or (self.kind >= 10000 and self.kind < 20000) @property def is_auth_response_event(self) -> bool: @@ -181,9 +181,14 @@ class NostrEvent(BaseModel): def is_delete_event(self) -> bool: return self.kind == 5 + @property + def is_regular_event(self) -> bool: + return self.kind >= 1000 and self.kind < 10000 + @property def is_ephemeral_event(self) -> bool: - return self.kind in [22242] + return self.kind >= 20000 and self.kind < 30000 + def check_signature(self): event_id = self.event_id diff --git a/tests/test_events.py b/tests/test_events.py index 6af5497..5b33c90 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -59,7 +59,7 @@ async def test_valid_event_crud(valid_events: List[EventFixture]): # insert all events in DB before doing an query for e in all_events: - await create_event(RELAY_ID, e) + await create_event(RELAY_ID, e, None) for f in valid_events: await get_by_id(f.data, f.name) From afde9ae37c7f7d14d353f0b330c07b67b466d6eb Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 09:55:57 +0200 Subject: [PATCH 040/114] doc: mark NIP16 done --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8570d30..b37939a 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ Install this extension into your LNbits instance. - [ ] **NIP-12**: Generic Tag Queries - todo - [x] **NIP-15**: End of Stored Events Notice - - [ ] **NIP-16**: Event Treatment + - [x] **NIP-16**: Event Treatment - [x] Regular Events - - [ ] Replaceable Events - - [ ] Ephemeral Events + - [x] Replaceable Events + - [x] Ephemeral Events - [x] **NIP-20**: Command Results - todo: use correct prefixes - [x] **NIP-22**: Event created_at Limits From 1dc6ddf9c59492ccdb52d0892b6e44c74672a6c9 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 11:08:32 +0200 Subject: [PATCH 041/114] doc: create relay --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b37939a..a6a28fc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,18 @@ Install this extension into your LNbits instance. - [ ] **NIP-50**: Search Capability - todo -### Development +## Usage +### Create Relay +Creating a new relay is straightforward. Just click `New Relay` then enter the Relay Info. +> *Note*: admin users can select a relay id. Regular users will be assigned a generated relay id. +The relay can be activated/deactivated. + +![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) + +### Configure Relay + + +## Development Create Symbolic Link: ``` From fd18ebe015c3f93fb15dba4dec60777d312c5cf3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 11:40:09 +0200 Subject: [PATCH 042/114] doc: payment basics --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index a6a28fc..5d3ae22 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,17 @@ The relay can be activated/deactivated. ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) ### Configure Relay +Find your Relay in the list and click the expand button (`+`) to configure it. + +**Relay Info** +This tab contains data according to `NIP-11` (Relay Information Document). +> **Note**: the `domain` is added automatically and shoud be corrected manually if needed. This value is used for `NIP-42` (Authentication of clients to relays) +![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) + + +**Payment** +By default the relay is free to access, but it can be configured to ask for payments. +![image](https://user-images.githubusercontent.com/2951406/219603153-a1564722-621d-494a-9e7e-22e93d42fe89.png) ## Development From de25a7a12dd03ea3d333d4ad98f22c0b139e5455 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 11:54:05 +0200 Subject: [PATCH 043/114] doc: basic config tab --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5d3ae22..a6d6004 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,25 @@ Find your Relay in the list and click the expand button (`+`) to configure it. **Relay Info** This tab contains data according to `NIP-11` (Relay Information Document). > **Note**: the `domain` is added automatically and shoud be corrected manually if needed. This value is used for `NIP-42` (Authentication of clients to relays) -![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) +- ![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) **Payment** + By default the relay is free to access, but it can be configured to ask for payments. -![image](https://user-images.githubusercontent.com/2951406/219603153-a1564722-621d-494a-9e7e-22e93d42fe89.png) +It is encourage to also activate the `Require Auth` option for paid relays. + +> **Note**: check the info button (`I`) tooltip for a description of each field. + +- ![image](https://user-images.githubusercontent.com/2951406/219609779-1513ad00-e816-4b4f-8e1e-459e5e1c586f.png) + +Click on the Relay ID (or visit `https://{your_domain}/nostrrelay/${relay_id}`) for the Relay public page. +Here the entry and storage fees can be paid. + +- ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) + +**Config** +- ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) ## Development From d38b3c73ffd541c0dc8396179f3b0aa16eb734b6 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 12:06:07 +0200 Subject: [PATCH 044/114] doc: add Config --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a6d6004..4968c05 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ Here the entry and storage fees can be paid. - ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) **Config** +Configure `NIP-22` (Event created_at Limits), `NIP-42` (Authentication of clients to relays) and other Relay parameters. +Some configurations are not standard (`NIPs`) but they help control what clients are allowed to do, thus blocking (some) attack vectors. + - ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) From bae7d284b3093a435b9af07299e942478dca347d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 12:13:39 +0200 Subject: [PATCH 045/114] doc: add accounts --- README.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4968c05..b4441b5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ Creating a new relay is straightforward. Just click `New Relay` then enter the R > *Note*: admin users can select a relay id. Regular users will be assigned a generated relay id. The relay can be activated/deactivated. -![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) +- New Relay Dialog + - ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) ### Configure Relay Find your Relay in the list and click the expand button (`+`) to configure it. @@ -54,7 +55,9 @@ Find your Relay in the list and click the expand button (`+`) to configure it. **Relay Info** This tab contains data according to `NIP-11` (Relay Information Document). > **Note**: the `domain` is added automatically and shoud be corrected manually if needed. This value is used for `NIP-42` (Authentication of clients to relays) -- ![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) + +- **Relay Info Tab** + - ![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) **Payment** @@ -64,20 +67,31 @@ It is encourage to also activate the `Require Auth` option for paid relays. > **Note**: check the info button (`I`) tooltip for a description of each field. -- ![image](https://user-images.githubusercontent.com/2951406/219609779-1513ad00-e816-4b4f-8e1e-459e5e1c586f.png) +- **Payment Config Tab** + - ![image](https://user-images.githubusercontent.com/2951406/219609779-1513ad00-e816-4b4f-8e1e-459e5e1c586f.png) Click on the Relay ID (or visit `https://{your_domain}/nostrrelay/${relay_id}`) for the Relay public page. Here the entry and storage fees can be paid. -- ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) +- **Relay Public Page** + - ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) **Config** Configure `NIP-22` (Event created_at Limits), `NIP-42` (Authentication of clients to relays) and other Relay parameters. Some configurations are not standard (`NIPs`) but they help control what clients are allowed to do, thus blocking (some) attack vectors. -- ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) +- **Config Tab** + - ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) +**Accounts** +Allows the Relay operator to `Block` or `Allow` certain accounts. +If an account is `allowed` then it is not required to `pay to join`. +When an account is `blocked` it does not matter if it `paid to join` or if it is `allowed`. + +- **Accounts Tab** + - ![image](https://user-images.githubusercontent.com/2951406/219615500-8ca98580-dc3d-4163-b321-ae9279d47a98.png) + ## Development Create Symbolic Link: From 012d861d162232f4275148fe7cfe5f2ff3fccc9d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 12:17:47 +0200 Subject: [PATCH 046/114] doc: small fixes --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b4441b5..7d9d106 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ Install this extension into your LNbits instance. ## Usage ### Create Relay Creating a new relay is straightforward. Just click `New Relay` then enter the Relay Info. -> *Note*: admin users can select a relay id. Regular users will be assigned a generated relay id. +> **Note**: admin users can select a relay id. Regular users will be assigned a generated relay id. The relay can be activated/deactivated. -- New Relay Dialog +- **New Relay Dialog** - ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) ### Configure Relay @@ -76,8 +76,11 @@ Here the entry and storage fees can be paid. - **Relay Public Page** - ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) + **Config** -Configure `NIP-22` (Event created_at Limits), `NIP-42` (Authentication of clients to relays) and other Relay parameters. + +Configure `NIP-22` (_Event `created_at` Limits_), `NIP-42` (_Authentication of clients to relays_) and other Relay parameters. + Some configurations are not standard (`NIPs`) but they help control what clients are allowed to do, thus blocking (some) attack vectors. - **Config Tab** @@ -85,8 +88,11 @@ Some configurations are not standard (`NIPs`) but they help control what clients **Accounts** + Allows the Relay operator to `Block` or `Allow` certain accounts. + If an account is `allowed` then it is not required to `pay to join`. + When an account is `blocked` it does not matter if it `paid to join` or if it is `allowed`. - **Accounts Tab** From eaf0979254f163b1d14b92114df3fed60ff768bf Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 12:19:45 +0200 Subject: [PATCH 047/114] doc: small fixes --- README.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7d9d106..e8c35a5 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,6 @@ ## One click and spin up your own Nostr relay. Share with the world, or use privately. - -### Usage -Install this extension into your LNbits instance. -.... - - ## Supported NIPs - [x] **NIP-01**: Basic protocol flow - [x] **NIP-02**: Contact List and Petnames @@ -40,7 +34,7 @@ Install this extension into your LNbits instance. - [ ] **NIP-50**: Search Capability - todo -## Usage + ### Create Relay Creating a new relay is straightforward. Just click `New Relay` then enter the Relay Info. > **Note**: admin users can select a relay id. Regular users will be assigned a generated relay id. @@ -83,6 +77,8 @@ Configure `NIP-22` (_Event `created_at` Limits_), `NIP-42` (_Authentication of c Some configurations are not standard (`NIPs`) but they help control what clients are allowed to do, thus blocking (some) attack vectors. +> **Note**: check the info button (`I`) tooltip for a description of each field. + - **Config Tab** - ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) From 8c24109dd35dfc6b112f15886f61fd2c17df748c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 12:21:02 +0200 Subject: [PATCH 048/114] doc: fix headers --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e8c35a5..f87fd20 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - todo -### Create Relay +## Create Relay Creating a new relay is straightforward. Just click `New Relay` then enter the Relay Info. > **Note**: admin users can select a relay id. Regular users will be assigned a generated relay id. The relay can be activated/deactivated. @@ -43,10 +43,10 @@ The relay can be activated/deactivated. - **New Relay Dialog** - ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) -### Configure Relay +## Configure Relay Find your Relay in the list and click the expand button (`+`) to configure it. -**Relay Info** +### Relay Info This tab contains data according to `NIP-11` (Relay Information Document). > **Note**: the `domain` is added automatically and shoud be corrected manually if needed. This value is used for `NIP-42` (Authentication of clients to relays) @@ -54,7 +54,7 @@ This tab contains data according to `NIP-11` (Relay Information Document). - ![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) -**Payment** +### Payment By default the relay is free to access, but it can be configured to ask for payments. It is encourage to also activate the `Require Auth` option for paid relays. @@ -71,7 +71,7 @@ Here the entry and storage fees can be paid. - ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) -**Config** +### Config Configure `NIP-22` (_Event `created_at` Limits_), `NIP-42` (_Authentication of clients to relays_) and other Relay parameters. @@ -83,7 +83,7 @@ Some configurations are not standard (`NIPs`) but they help control what clients - ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) -**Accounts** +### Accounts Allows the Relay operator to `Block` or `Allow` certain accounts. From 30ab2b8f7025cbeba67d5a74a4a79942aaf29a5b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 10:30:15 +0200 Subject: [PATCH 049/114] chore: testing --- client_manager.py | 86 +++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/client_manager.py b/client_manager.py index 775d3dd..bab80ae 100644 --- a/client_manager.py +++ b/client_manager.py @@ -97,8 +97,7 @@ class NostrClientConnection: self._auth_challenge: Optional[str] = None self._auth_challenge_created_at = 0 - self._last_event_timestamp = 0 # in hours - self._event_count_per_timestamp = 0 + self.event_validator = EventValidator(self.relay_id) self.broadcast_event: Optional[ Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] @@ -184,7 +183,7 @@ class NostrClientConnection: resp_nip20: List[Any] = ["OK", e.id] if e.is_auth_response_event: - valid, message = self._validate_auth_event(e) + valid, message = self.event_validator.validate_auth_event(e, self._auth_challenge) if not valid: resp_nip20 += [valid, message] await self._send_msg(resp_nip20) @@ -201,7 +200,8 @@ class NostrClientConnection: await self._send_msg(resp_nip20) return None - valid, message = await self._validate_write(e) + publisher_pubkey = self.pubkey if self.pubkey else e.pubkey + valid, message = await self.event_validator.validate_write(e, publisher_pubkey) if not valid: resp_nip20 += [valid, message] await self._send_msg(resp_nip20) @@ -285,7 +285,48 @@ class NostrClientConnection: and len(self.filters) >= self.client_config.max_client_filters ) - def _validate_auth_event(self, e: NostrEvent) -> Tuple[bool, str]: + + def _auth_challenge_expired(self): + if self._auth_challenge_created_at == 0: + return True + current_time_seconds = round(time.time()) + chanllenge_max_age_seconds = 300 # 5 min + return ( + current_time_seconds - self._auth_challenge_created_at + ) >= chanllenge_max_age_seconds + + def _current_auth_challenge(self): + if self._auth_challenge_expired(): + self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash() + self._auth_challenge_created_at = round(time.time()) + return self._auth_challenge + + + +class EventValidator: + + def __init__(self, relay_id: str): + self.relay_id = relay_id + self.client_config: RelaySpec + + self._last_event_timestamp = 0 # in hours + self._event_count_per_timestamp = 0 + + async def validate_write(self, e: NostrEvent, publisher_pubkey: str) -> Tuple[bool, str]: + valid, message = self._validate_event(e) + if not valid: + return (valid, message) + + if e.is_ephemeral_event: + return True, "" + + valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes) + if not valid: + return (valid, message) + + return True, "" + + def validate_auth_event(self, e: NostrEvent, auth_challenge: Optional[str]) -> Tuple[bool, str]: valid, message = self._validate_event(e) if not valid: return (valid, message) @@ -298,26 +339,11 @@ class NostrClientConnection: if self.client_config.domain != extract_domain(relay_tag[0]): return False, "error: wrong relay domain for auth event" - if self._auth_challenge != challenge_tag[0]: + if auth_challenge != challenge_tag[0]: return False, "error: wrong chanlange value for auth event" return True, "" - async def _validate_write(self, e: NostrEvent) -> Tuple[bool, str]: - valid, message = self._validate_event(e) - if not valid: - return (valid, message) - - if e.is_ephemeral_event: - return True, "" - - publisher_pubkey = self.pubkey if self.pubkey else e.pubkey - valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes) - if not valid: - return (valid, message) - - return True, "" - def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]: if self._exceeded_max_events_per_hour(): return False, f"Exceeded max events per hour limit'!" @@ -372,6 +398,7 @@ class NostrClientConnection: return True, "" + def _exceeded_max_events_per_hour(self) -> bool: if self.client_config.max_events_per_hour == 0: return False @@ -395,19 +422,4 @@ class NostrClientConnection: if self.client_config.created_at_in_future != 0: if created_at > (current_time + self.client_config.created_at_in_future): return False, "created_at is too much into the future" - return True, "" - - def _auth_challenge_expired(self): - if self._auth_challenge_created_at == 0: - return True - current_time_seconds = round(time.time()) - chanllenge_max_age_seconds = 300 # 5 min - return ( - current_time_seconds - self._auth_challenge_created_at - ) >= chanllenge_max_age_seconds - - def _current_auth_challenge(self): - if self._auth_challenge_expired(): - self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash() - self._auth_challenge_created_at = round(time.time()) - return self._auth_challenge + return True, "" \ No newline at end of file From d5d8b5e1b5c6517e40e5bd8e121f4492efd27877 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 13:34:34 +0200 Subject: [PATCH 050/114] refactor: init callbacks --- client_manager.py | 21 ++++++++++++++++----- tests/test_clients.py | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/client_manager.py b/client_manager.py index bab80ae..dccc75e 100644 --- a/client_manager.py +++ b/client_manager.py @@ -79,13 +79,12 @@ class NostrClientManager: return False return True - def _set_client_callbacks(self, client): - setattr(client, "broadcast_event", self.broadcast_event) - + def _set_client_callbacks(self, client: "NostrClientConnection"): def get_client_config() -> RelaySpec: return self.get_relay_config(client.relay_id) setattr(client, "get_client_config", get_client_config) + client.init_callbacks(self.broadcast_event, get_client_config) class NostrClientConnection: @@ -129,6 +128,12 @@ class NostrClientConnection: except: pass + def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable): + setattr(self, "broadcast_event", broadcast_event) + setattr(self, "get_client_config", get_client_config) + setattr(self.event_validator, "get_client_config", get_client_config) + + async def notify_event(self, event: NostrEvent) -> bool: if self._is_direct_message_for_other(event): return False @@ -285,7 +290,6 @@ class NostrClientConnection: and len(self.filters) >= self.client_config.max_client_filters ) - def _auth_challenge_expired(self): if self._auth_challenge_created_at == 0: return True @@ -307,11 +311,12 @@ class EventValidator: def __init__(self, relay_id: str): self.relay_id = relay_id - self.client_config: RelaySpec self._last_event_timestamp = 0 # in hours self._event_count_per_timestamp = 0 + self.get_client_config: Optional[Callable[[], RelaySpec]] = None + async def validate_write(self, e: NostrEvent, publisher_pubkey: str) -> Tuple[bool, str]: valid, message = self._validate_event(e) if not valid: @@ -344,6 +349,12 @@ class EventValidator: return True, "" + @property + def client_config(self) -> RelaySpec: + if not self.get_client_config: + raise Exception("EventValidator not ready!") + return self.get_client_config() + def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]: if self._exceeded_max_events_per_hour(): return False, f"Exceeded max events per hour limit'!" diff --git a/tests/test_clients.py b/tests/test_clients.py index ec493d4..eff5902 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -96,7 +96,7 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): assert ( len(ws_alice.sent_messages) == 4 - ), "Alice: Expected 3 confirmations to be sent" + ), "Alice: Expected 4 confirmations to be sent" assert ws_alice.sent_messages[0] == dumps( alice["meta_response"] ), "Alice: Wrong confirmation for meta" From 818072fe293a54f28fbd8f53005671ca2c37f3c3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 13:35:44 +0200 Subject: [PATCH 051/114] refactor: rename `def client_config` to `def config` --- client_manager.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/client_manager.py b/client_manager.py index dccc75e..913d674 100644 --- a/client_manager.py +++ b/client_manager.py @@ -152,7 +152,7 @@ class NostrClientConnection: """ if not event.is_direct_message: return False - if not self.client_config.event_requires_auth(event.kind): + if not self.config.event_requires_auth(event.kind): return False if not self.pubkey: return True @@ -196,7 +196,7 @@ class NostrClientConnection: self.pubkey = e.pubkey return None - if not self.pubkey and self.client_config.event_requires_auth(e.kind): + if not self.pubkey and self.config.event_requires_auth(e.kind): await self._send_msg(["AUTH", self._current_auth_challenge()]) resp_nip20 += [ False, @@ -234,7 +234,7 @@ class NostrClientConnection: await self._send_msg(resp_nip20) @property - def client_config(self) -> RelaySpec: + def config(self) -> RelaySpec: if not self.get_client_config: raise Exception("Client not ready!") return self.get_client_config() @@ -251,7 +251,7 @@ class NostrClientConnection: await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: - if not self.pubkey and self.client_config.require_auth_filter: + if not self.pubkey and self.config.require_auth_filter: return [["AUTH", self._current_auth_challenge()]] filter.subscription_id = subscription_id @@ -260,11 +260,11 @@ class NostrClientConnection: return [ [ "NOTICE", - f"Maximum number of filters ({self.client_config.max_client_filters}) exceeded.", + f"Maximum number of filters ({self.config.max_client_filters}) exceeded.", ] ] - filter.enforce_limit(self.client_config.limit_per_filter) + filter.enforce_limit(self.config.limit_per_filter) self.filters.append(filter) events = await get_events(self.relay_id, filter) events = [e for e in events if not self._is_direct_message_for_other(e)] @@ -286,8 +286,8 @@ class NostrClientConnection: def _can_add_filter(self) -> bool: return ( - self.client_config.max_client_filters != 0 - and len(self.filters) >= self.client_config.max_client_filters + self.config.max_client_filters != 0 + and len(self.filters) >= self.config.max_client_filters ) def _auth_challenge_expired(self): @@ -341,7 +341,7 @@ class EventValidator: if len(relay_tag) == 0 or len(challenge_tag) == 0: return False, "error: NIP42 tags are missing for auth event" - if self.client_config.domain != extract_domain(relay_tag[0]): + if self.config.domain != extract_domain(relay_tag[0]): return False, "error: wrong relay domain for auth event" if auth_challenge != challenge_tag[0]: @@ -350,7 +350,7 @@ class EventValidator: return True, "" @property - def client_config(self) -> RelaySpec: + def config(self) -> RelaySpec: if not self.get_client_config: raise Exception("EventValidator not ready!") return self.get_client_config() @@ -373,7 +373,7 @@ class EventValidator: async def _validate_storage( self, pubkey: str, event_size_bytes: int ) -> Tuple[bool, str]: - if self.client_config.is_read_only_relay: + if self.config.is_read_only_relay: return False, "Cannot write event, relay is read-only" account = await get_account(self.relay_id, pubkey) @@ -386,17 +386,17 @@ class EventValidator: f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!", ) - if not account.can_join and self.client_config.is_paid_relay: + if not account.can_join and self.config.is_paid_relay: return False, f"This is a paid relay: '{self.relay_id}'" stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) total_available_storage = ( - account.storage + self.client_config.free_storage_bytes_value + account.storage + self.config.free_storage_bytes_value ) if (stored_bytes + event_size_bytes) <= total_available_storage: return True, "" - if self.client_config.full_storage_action == "block": + if self.config.full_storage_action == "block": return ( False, f"Cannot write event, no more storage available for public key: '{pubkey}'", @@ -411,7 +411,7 @@ class EventValidator: def _exceeded_max_events_per_hour(self) -> bool: - if self.client_config.max_events_per_hour == 0: + if self.config.max_events_per_hour == 0: return False current_time = round(time.time() / 3600) @@ -422,15 +422,15 @@ class EventValidator: self._event_count_per_timestamp = 0 return ( - self._event_count_per_timestamp > self.client_config.max_events_per_hour + self._event_count_per_timestamp > self.config.max_events_per_hour ) def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]: current_time = round(time.time()) - if self.client_config.created_at_in_past != 0: - if created_at < (current_time - self.client_config.created_at_in_past): + if self.config.created_at_in_past != 0: + if created_at < (current_time - self.config.created_at_in_past): return False, "created_at is too much into the past" - if self.client_config.created_at_in_future != 0: - if created_at > (current_time + self.client_config.created_at_in_future): + if self.config.created_at_in_future != 0: + if created_at > (current_time + self.config.created_at_in_future): return False, "created_at is too much into the future" return True, "" \ No newline at end of file From c46c903703b0e85282cb3ff4a49d9f66bc766cd7 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 13:57:02 +0200 Subject: [PATCH 052/114] refactor: move client-manager --- client_manager.py => relay/client_manager.py | 6 +++--- tests/test_clients.py | 2 +- views_api.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename client_manager.py => relay/client_manager.py (99%) diff --git a/client_manager.py b/relay/client_manager.py similarity index 99% rename from client_manager.py rename to relay/client_manager.py index 913d674..4c8eade 100644 --- a/client_manager.py +++ b/relay/client_manager.py @@ -7,7 +7,7 @@ from loguru import logger from lnbits.helpers import urlsafe_short_hash -from .crud import ( +from ..crud import ( create_event, delete_events, get_account, @@ -18,8 +18,8 @@ from .crud import ( mark_events_deleted, prune_old_events, ) -from .helpers import extract_domain -from .models import NostrAccount, NostrEvent, NostrEventType, NostrFilter, RelaySpec +from ..helpers import extract_domain +from ..models import NostrAccount, NostrEvent, NostrEventType, NostrFilter, RelaySpec class NostrClientManager: diff --git a/tests/test_clients.py b/tests/test_clients.py index eff5902..e4f0f29 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,7 +6,7 @@ import pytest from fastapi import WebSocket from loguru import logger -from lnbits.extensions.nostrrelay.client_manager import ( # type: ignore +from lnbits.extensions.nostrrelay.relay.client_manager import ( # type: ignore NostrClientConnection, NostrClientManager, ) diff --git a/views_api.py b/views_api.py index 1d1c843..a3321f2 100644 --- a/views_api.py +++ b/views_api.py @@ -16,7 +16,7 @@ from lnbits.decorators import ( from lnbits.helpers import urlsafe_short_hash from . import nostrrelay_ext -from .client_manager import NostrClientConnection, NostrClientManager +from .relay.client_manager import NostrClientConnection, NostrClientManager from .crud import ( create_account, create_relay, From aa68d2a79a77a7ee50fb6fc7b5f0bf7bf2cbcfc7 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:00:19 +0200 Subject: [PATCH 053/114] refactor: extract `EventValidator` --- relay/client_manager.py | 137 +-------------------------------------- relay/event_validator.py | 135 ++++++++++++++++++++++++++++++++++++++ tests/test_clients.py | 2 +- views_api.py | 2 +- 4 files changed, 140 insertions(+), 136 deletions(-) create mode 100644 relay/event_validator.py diff --git a/relay/client_manager.py b/relay/client_manager.py index 4c8eade..a039e8b 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -1,6 +1,6 @@ import json import time -from typing import Any, Awaitable, Callable, List, Optional, Tuple +from typing import Any, Awaitable, Callable, List, Optional from fastapi import WebSocket from loguru import logger @@ -10,16 +10,13 @@ from lnbits.helpers import urlsafe_short_hash from ..crud import ( create_event, delete_events, - get_account, get_config_for_all_active_relays, get_event, get_events, - get_storage_for_public_key, mark_events_deleted, - prune_old_events, ) -from ..helpers import extract_domain -from ..models import NostrAccount, NostrEvent, NostrEventType, NostrFilter, RelaySpec +from ..models import NostrEvent, NostrEventType, NostrFilter, RelaySpec +from .event_validator import EventValidator class NostrClientManager: @@ -306,131 +303,3 @@ class NostrClientConnection: return self._auth_challenge - -class EventValidator: - - def __init__(self, relay_id: str): - self.relay_id = relay_id - - self._last_event_timestamp = 0 # in hours - self._event_count_per_timestamp = 0 - - self.get_client_config: Optional[Callable[[], RelaySpec]] = None - - async def validate_write(self, e: NostrEvent, publisher_pubkey: str) -> Tuple[bool, str]: - valid, message = self._validate_event(e) - if not valid: - return (valid, message) - - if e.is_ephemeral_event: - return True, "" - - valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes) - if not valid: - return (valid, message) - - return True, "" - - def validate_auth_event(self, e: NostrEvent, auth_challenge: Optional[str]) -> Tuple[bool, str]: - valid, message = self._validate_event(e) - if not valid: - return (valid, message) - - relay_tag = e.tag_values("relay") - challenge_tag = e.tag_values("challenge") - if len(relay_tag) == 0 or len(challenge_tag) == 0: - return False, "error: NIP42 tags are missing for auth event" - - if self.config.domain != extract_domain(relay_tag[0]): - return False, "error: wrong relay domain for auth event" - - if auth_challenge != challenge_tag[0]: - return False, "error: wrong chanlange value for auth event" - - return True, "" - - @property - def config(self) -> RelaySpec: - if not self.get_client_config: - raise Exception("EventValidator not ready!") - return self.get_client_config() - - def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]: - if self._exceeded_max_events_per_hour(): - return False, f"Exceeded max events per hour limit'!" - - try: - e.check_signature() - except ValueError: - return False, "invalid: wrong event `id` or `sig`" - - in_range, message = self._created_at_in_range(e.created_at) - if not in_range: - return False, message - - return True, "" - - async def _validate_storage( - self, pubkey: str, event_size_bytes: int - ) -> Tuple[bool, str]: - if self.config.is_read_only_relay: - return False, "Cannot write event, relay is read-only" - - account = await get_account(self.relay_id, pubkey) - if not account: - account = NostrAccount.null_account() - - if account.blocked: - return ( - False, - f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!", - ) - - if not account.can_join and self.config.is_paid_relay: - return False, f"This is a paid relay: '{self.relay_id}'" - - stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) - total_available_storage = ( - account.storage + self.config.free_storage_bytes_value - ) - if (stored_bytes + event_size_bytes) <= total_available_storage: - return True, "" - - if self.config.full_storage_action == "block": - return ( - False, - f"Cannot write event, no more storage available for public key: '{pubkey}'", - ) - - if event_size_bytes > total_available_storage: - return False, "Message is too large. Not enough storage available for it." - - await prune_old_events(self.relay_id, pubkey, event_size_bytes) - - return True, "" - - - def _exceeded_max_events_per_hour(self) -> bool: - if self.config.max_events_per_hour == 0: - return False - - current_time = round(time.time() / 3600) - if self._last_event_timestamp == current_time: - self._event_count_per_timestamp += 1 - else: - self._last_event_timestamp = current_time - self._event_count_per_timestamp = 0 - - return ( - self._event_count_per_timestamp > self.config.max_events_per_hour - ) - - def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]: - current_time = round(time.time()) - if self.config.created_at_in_past != 0: - if created_at < (current_time - self.config.created_at_in_past): - return False, "created_at is too much into the past" - if self.config.created_at_in_future != 0: - if created_at > (current_time + self.config.created_at_in_future): - return False, "created_at is too much into the future" - return True, "" \ No newline at end of file diff --git a/relay/event_validator.py b/relay/event_validator.py new file mode 100644 index 0000000..d6ec25e --- /dev/null +++ b/relay/event_validator.py @@ -0,0 +1,135 @@ +import time +from typing import Callable, Optional, Tuple + +from ..crud import get_account, get_storage_for_public_key, prune_old_events +from ..helpers import extract_domain +from ..models import NostrAccount, NostrEvent, RelaySpec + + +class EventValidator: + + def __init__(self, relay_id: str): + self.relay_id = relay_id + + self._last_event_timestamp = 0 # in hours + self._event_count_per_timestamp = 0 + + self.get_client_config: Optional[Callable[[], RelaySpec]] = None + + async def validate_write(self, e: NostrEvent, publisher_pubkey: str) -> Tuple[bool, str]: + valid, message = self._validate_event(e) + if not valid: + return (valid, message) + + if e.is_ephemeral_event: + return True, "" + + valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes) + if not valid: + return (valid, message) + + return True, "" + + def validate_auth_event(self, e: NostrEvent, auth_challenge: Optional[str]) -> Tuple[bool, str]: + valid, message = self._validate_event(e) + if not valid: + return (valid, message) + + relay_tag = e.tag_values("relay") + challenge_tag = e.tag_values("challenge") + if len(relay_tag) == 0 or len(challenge_tag) == 0: + return False, "error: NIP42 tags are missing for auth event" + + if self.config.domain != extract_domain(relay_tag[0]): + return False, "error: wrong relay domain for auth event" + + if auth_challenge != challenge_tag[0]: + return False, "error: wrong chanlange value for auth event" + + return True, "" + + @property + def config(self) -> RelaySpec: + if not self.get_client_config: + raise Exception("EventValidator not ready!") + return self.get_client_config() + + def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]: + if self._exceeded_max_events_per_hour(): + return False, f"Exceeded max events per hour limit'!" + + try: + e.check_signature() + except ValueError: + return False, "invalid: wrong event `id` or `sig`" + + in_range, message = self._created_at_in_range(e.created_at) + if not in_range: + return False, message + + return True, "" + + async def _validate_storage( + self, pubkey: str, event_size_bytes: int + ) -> Tuple[bool, str]: + if self.config.is_read_only_relay: + return False, "Cannot write event, relay is read-only" + + account = await get_account(self.relay_id, pubkey) + if not account: + account = NostrAccount.null_account() + + if account.blocked: + return ( + False, + f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!", + ) + + if not account.can_join and self.config.is_paid_relay: + return False, f"This is a paid relay: '{self.relay_id}'" + + stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) + total_available_storage = ( + account.storage + self.config.free_storage_bytes_value + ) + if (stored_bytes + event_size_bytes) <= total_available_storage: + return True, "" + + if self.config.full_storage_action == "block": + return ( + False, + f"Cannot write event, no more storage available for public key: '{pubkey}'", + ) + + if event_size_bytes > total_available_storage: + return False, "Message is too large. Not enough storage available for it." + + await prune_old_events(self.relay_id, pubkey, event_size_bytes) + + return True, "" + + + def _exceeded_max_events_per_hour(self) -> bool: + if self.config.max_events_per_hour == 0: + return False + + current_time = round(time.time() / 3600) + if self._last_event_timestamp == current_time: + self._event_count_per_timestamp += 1 + else: + self._last_event_timestamp = current_time + self._event_count_per_timestamp = 0 + + return ( + self._event_count_per_timestamp > self.config.max_events_per_hour + ) + + def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]: + current_time = round(time.time()) + if self.config.created_at_in_past != 0: + if created_at < (current_time - self.config.created_at_in_past): + return False, "created_at is too much into the past" + if self.config.created_at_in_future != 0: + if created_at > (current_time + self.config.created_at_in_future): + return False, "created_at is too much into the future" + return True, "" \ No newline at end of file diff --git a/tests/test_clients.py b/tests/test_clients.py index e4f0f29..dea0302 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,11 +6,11 @@ import pytest from fastapi import WebSocket from loguru import logger +from lnbits.extensions.nostrrelay.models import RelaySpec # type: ignore from lnbits.extensions.nostrrelay.relay.client_manager import ( # type: ignore NostrClientConnection, NostrClientManager, ) -from lnbits.extensions.nostrrelay.models import RelaySpec # type: ignore from .helpers import get_fixtures diff --git a/views_api.py b/views_api.py index a3321f2..4b31a43 100644 --- a/views_api.py +++ b/views_api.py @@ -16,7 +16,6 @@ from lnbits.decorators import ( from lnbits.helpers import urlsafe_short_hash from . import nostrrelay_ext -from .relay.client_manager import NostrClientConnection, NostrClientManager from .crud import ( create_account, create_relay, @@ -32,6 +31,7 @@ from .crud import ( ) from .helpers import extract_domain, normalize_public_key from .models import BuyOrder, NostrAccount, NostrPartialAccount, NostrRelay +from .relay.client_manager import NostrClientConnection, NostrClientManager client_manager = NostrClientManager() From c42d81f6964e1229bd4f5b553876fa24c11232b5 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:05:50 +0200 Subject: [PATCH 054/114] refactor: extract `client_connection` --- relay/__init__.py | 0 relay/client_connection.py | 237 ++++++++++++++++++++++++++++++++++ relay/client_manager.py | 253 ++----------------------------------- tests/test_clients.py | 6 +- 4 files changed, 249 insertions(+), 247 deletions(-) create mode 100644 relay/__init__.py create mode 100644 relay/client_connection.py diff --git a/relay/__init__.py b/relay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/relay/client_connection.py b/relay/client_connection.py new file mode 100644 index 0000000..d7672a6 --- /dev/null +++ b/relay/client_connection.py @@ -0,0 +1,237 @@ +import json +import time +from typing import Any, Awaitable, Callable, List, Optional + +from fastapi import WebSocket +from loguru import logger + +from lnbits.helpers import urlsafe_short_hash + +from ..crud import ( + create_event, + delete_events, + get_event, + get_events, + mark_events_deleted, +) +from ..models import NostrEvent, NostrEventType, NostrFilter, RelaySpec +from .event_validator import EventValidator + + +class NostrClientConnection: + def __init__(self, relay_id: str, websocket: WebSocket): + self.websocket = websocket + self.relay_id = relay_id + self.filters: List[NostrFilter] = [] + self.pubkey: Optional[str] = None # set if authenticated + self._auth_challenge: Optional[str] = None + self._auth_challenge_created_at = 0 + + self.event_validator = EventValidator(self.relay_id) + + self.broadcast_event: Optional[ + Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] + ] = None + self.get_client_config: Optional[Callable[[], RelaySpec]] = None + + async def start(self): + await self.websocket.accept() + while True: + json_data = await self.websocket.receive_text() + try: + data = json.loads(json_data) + + resp = await self._handle_message(data) + for r in resp: + await self._send_msg(r) + except Exception as e: + logger.warning(e) + + async def stop(self, reason: Optional[str]): + message = reason if reason else "Server closed webocket" + try: + await self._send_msg(["NOTICE", message]) + except: + pass + + try: + await self.websocket.close(reason=reason) + except: + pass + + def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable): + setattr(self, "broadcast_event", broadcast_event) + setattr(self, "get_client_config", get_client_config) + setattr(self.event_validator, "get_client_config", get_client_config) + + + async def notify_event(self, event: NostrEvent) -> bool: + if self._is_direct_message_for_other(event): + return False + + for filter in self.filters: + if filter.matches(event): + resp = event.serialize_response(filter.subscription_id) + await self._send_msg(resp) + return True + return False + + def _is_direct_message_for_other(self, event: NostrEvent) -> bool: + """ + Direct messages are not inteded to be boradcast (even if encrypted). + If the server requires AUTH for kind '4' then direct message will be sent only to the intended client. + """ + if not event.is_direct_message: + return False + if not self.config.event_requires_auth(event.kind): + return False + if not self.pubkey: + return True + if event.has_tag_value("p", self.pubkey): + return False + return True + + async def _broadcast_event(self, e: NostrEvent): + if self.broadcast_event: + await self.broadcast_event(self, e) + + async def _handle_message(self, data: List) -> List: + if len(data) < 2: + return [] + + message_type = data[0] + if message_type == NostrEventType.EVENT: + await self._handle_event(NostrEvent.parse_obj(data[1])) + return [] + if message_type == NostrEventType.REQ: + if len(data) != 3: + return [] + return await self._handle_request(data[1], NostrFilter.parse_obj(data[2])) + if message_type == NostrEventType.CLOSE: + self._handle_close(data[1]) + if message_type == NostrEventType.AUTH: + await self._handle_auth() + + return [] + + async def _handle_event(self, e: NostrEvent): + logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") + resp_nip20: List[Any] = ["OK", e.id] + + if e.is_auth_response_event: + valid, message = self.event_validator.validate_auth_event(e, self._auth_challenge) + if not valid: + resp_nip20 += [valid, message] + await self._send_msg(resp_nip20) + return None + self.pubkey = e.pubkey + return None + + if not self.pubkey and self.config.event_requires_auth(e.kind): + await self._send_msg(["AUTH", self._current_auth_challenge()]) + resp_nip20 += [ + False, + f"restricted: Relay requires authentication for events of kind '{e.kind}'", + ] + await self._send_msg(resp_nip20) + return None + + publisher_pubkey = self.pubkey if self.pubkey else e.pubkey + valid, message = await self.event_validator.validate_write(e, publisher_pubkey) + if not valid: + resp_nip20 += [valid, message] + await self._send_msg(resp_nip20) + return None + + try: + if e.is_replaceable_event: + await delete_events( + self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at) + ) + if not e.is_ephemeral_event: + await create_event(self.relay_id, e, self.pubkey) + await self._broadcast_event(e) + + if e.is_delete_event: + await self._handle_delete_event(e) + resp_nip20 += [True, ""] + except Exception as ex: + logger.debug(ex) + event = await get_event(self.relay_id, e.id) + # todo: handle NIP20 in detail + message = "error: failed to create event" + resp_nip20 += [event != None, message] + + await self._send_msg(resp_nip20) + + @property + def config(self) -> RelaySpec: + if not self.get_client_config: + raise Exception("Client not ready!") + return self.get_client_config() + + async def _send_msg(self, data: List): + await self.websocket.send_text(json.dumps(data)) + + async def _handle_delete_event(self, event: NostrEvent): + # NIP 09 + filter = NostrFilter(authors=[event.pubkey]) + filter.ids = [t[1] for t in event.tags if t[0] == "e"] + events_to_delete = await get_events(self.relay_id, filter, False) + ids = [e.id for e in events_to_delete if not e.is_delete_event] + await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) + + async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: + if not self.pubkey and self.config.require_auth_filter: + return [["AUTH", self._current_auth_challenge()]] + + filter.subscription_id = subscription_id + self._remove_filter(subscription_id) + if self._can_add_filter(): + return [ + [ + "NOTICE", + f"Maximum number of filters ({self.config.max_client_filters}) exceeded.", + ] + ] + + filter.enforce_limit(self.config.limit_per_filter) + self.filters.append(filter) + events = await get_events(self.relay_id, filter) + events = [e for e in events if not self._is_direct_message_for_other(e)] + serialized_events = [ + event.serialize_response(subscription_id) for event in events + ] + resp_nip15 = ["EOSE", subscription_id] + serialized_events.append(resp_nip15) + return serialized_events + + def _remove_filter(self, subscription_id: str): + self.filters = [f for f in self.filters if f.subscription_id != subscription_id] + + def _handle_close(self, subscription_id: str): + self._remove_filter(subscription_id) + + async def _handle_auth(self): + await self._send_msg(["AUTH", self._current_auth_challenge()]) + + def _can_add_filter(self) -> bool: + return ( + self.config.max_client_filters != 0 + and len(self.filters) >= self.config.max_client_filters + ) + + def _auth_challenge_expired(self): + if self._auth_challenge_created_at == 0: + return True + current_time_seconds = round(time.time()) + chanllenge_max_age_seconds = 300 # 5 min + return ( + current_time_seconds - self._auth_challenge_created_at + ) >= chanllenge_max_age_seconds + + def _current_auth_challenge(self): + if self._auth_challenge_expired(): + self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash() + self._auth_challenge_created_at = round(time.time()) + return self._auth_challenge diff --git a/relay/client_manager.py b/relay/client_manager.py index a039e8b..efdc9b4 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -1,22 +1,9 @@ -import json -import time -from typing import Any, Awaitable, Callable, List, Optional +from typing import List -from fastapi import WebSocket -from loguru import logger +from .client_connection import NostrClientConnection -from lnbits.helpers import urlsafe_short_hash - -from ..crud import ( - create_event, - delete_events, - get_config_for_all_active_relays, - get_event, - get_events, - mark_events_deleted, -) -from ..models import NostrEvent, NostrEventType, NostrFilter, RelaySpec -from .event_validator import EventValidator +from ..crud import get_config_for_all_active_relays +from ..models import NostrEvent, RelaySpec class NostrClientManager: @@ -25,7 +12,7 @@ class NostrClientManager: self._active_relays: dict = {} self._is_ready = False - async def add_client(self, c: "NostrClientConnection") -> bool: + async def add_client(self, c: NostrClientConnection) -> bool: if not self._is_ready: await self.init_relays() @@ -37,10 +24,10 @@ class NostrClientManager: return True - def remove_client(self, c: "NostrClientConnection"): + def remove_client(self, c: NostrClientConnection): self.clients(c.relay_id).remove(c) - async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): + async def broadcast_event(self, source: NostrClientConnection, event: NostrEvent): for client in self.clients(source.relay_id): await client.notify_event(event) @@ -60,7 +47,7 @@ class NostrClientManager: def get_relay_config(self, relay_id: str) -> RelaySpec: return self._active_relays[relay_id] - def clients(self, relay_id: str) -> List["NostrClientConnection"]: + def clients(self, relay_id: str) -> List[NostrClientConnection]: if relay_id not in self._clients: self._clients[relay_id] = [] return self._clients[relay_id] @@ -70,236 +57,16 @@ class NostrClientManager: if client.relay_id == relay_id: await client.stop(reason=f"Relay '{relay_id}' has been deactivated.") - async def _allow_client(self, c: "NostrClientConnection") -> bool: + async def _allow_client(self, c: NostrClientConnection) -> bool: if c.relay_id not in self._active_relays: await c.stop(reason=f"Relay '{c.relay_id}' is not active") return False return True - def _set_client_callbacks(self, client: "NostrClientConnection"): + def _set_client_callbacks(self, client: NostrClientConnection): def get_client_config() -> RelaySpec: return self.get_relay_config(client.relay_id) setattr(client, "get_client_config", get_client_config) client.init_callbacks(self.broadcast_event, get_client_config) - -class NostrClientConnection: - def __init__(self, relay_id: str, websocket: WebSocket): - self.websocket = websocket - self.relay_id = relay_id - self.filters: List[NostrFilter] = [] - self.pubkey: Optional[str] = None # set if authenticated - self._auth_challenge: Optional[str] = None - self._auth_challenge_created_at = 0 - - self.event_validator = EventValidator(self.relay_id) - - self.broadcast_event: Optional[ - Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] - ] = None - self.get_client_config: Optional[Callable[[], RelaySpec]] = None - - async def start(self): - await self.websocket.accept() - while True: - json_data = await self.websocket.receive_text() - try: - data = json.loads(json_data) - - resp = await self._handle_message(data) - for r in resp: - await self._send_msg(r) - except Exception as e: - logger.warning(e) - - async def stop(self, reason: Optional[str]): - message = reason if reason else "Server closed webocket" - try: - await self._send_msg(["NOTICE", message]) - except: - pass - - try: - await self.websocket.close(reason=reason) - except: - pass - - def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable): - setattr(self, "broadcast_event", broadcast_event) - setattr(self, "get_client_config", get_client_config) - setattr(self.event_validator, "get_client_config", get_client_config) - - - async def notify_event(self, event: NostrEvent) -> bool: - if self._is_direct_message_for_other(event): - return False - - for filter in self.filters: - if filter.matches(event): - resp = event.serialize_response(filter.subscription_id) - await self._send_msg(resp) - return True - return False - - def _is_direct_message_for_other(self, event: NostrEvent) -> bool: - """ - Direct messages are not inteded to be boradcast (even if encrypted). - If the server requires AUTH for kind '4' then direct message will be sent only to the intended client. - """ - if not event.is_direct_message: - return False - if not self.config.event_requires_auth(event.kind): - return False - if not self.pubkey: - return True - if event.has_tag_value("p", self.pubkey): - return False - return True - - async def _broadcast_event(self, e: NostrEvent): - if self.broadcast_event: - await self.broadcast_event(self, e) - - async def _handle_message(self, data: List) -> List: - if len(data) < 2: - return [] - - message_type = data[0] - if message_type == NostrEventType.EVENT: - await self._handle_event(NostrEvent.parse_obj(data[1])) - return [] - if message_type == NostrEventType.REQ: - if len(data) != 3: - return [] - return await self._handle_request(data[1], NostrFilter.parse_obj(data[2])) - if message_type == NostrEventType.CLOSE: - self._handle_close(data[1]) - if message_type == NostrEventType.AUTH: - await self._handle_auth() - - return [] - - async def _handle_event(self, e: NostrEvent): - logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") - resp_nip20: List[Any] = ["OK", e.id] - - if e.is_auth_response_event: - valid, message = self.event_validator.validate_auth_event(e, self._auth_challenge) - if not valid: - resp_nip20 += [valid, message] - await self._send_msg(resp_nip20) - return None - self.pubkey = e.pubkey - return None - - if not self.pubkey and self.config.event_requires_auth(e.kind): - await self._send_msg(["AUTH", self._current_auth_challenge()]) - resp_nip20 += [ - False, - f"restricted: Relay requires authentication for events of kind '{e.kind}'", - ] - await self._send_msg(resp_nip20) - return None - - publisher_pubkey = self.pubkey if self.pubkey else e.pubkey - valid, message = await self.event_validator.validate_write(e, publisher_pubkey) - if not valid: - resp_nip20 += [valid, message] - await self._send_msg(resp_nip20) - return None - - try: - if e.is_replaceable_event: - await delete_events( - self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at) - ) - if not e.is_ephemeral_event: - await create_event(self.relay_id, e, self.pubkey) - await self._broadcast_event(e) - - if e.is_delete_event: - await self._handle_delete_event(e) - resp_nip20 += [True, ""] - except Exception as ex: - logger.debug(ex) - event = await get_event(self.relay_id, e.id) - # todo: handle NIP20 in detail - message = "error: failed to create event" - resp_nip20 += [event != None, message] - - await self._send_msg(resp_nip20) - - @property - def config(self) -> RelaySpec: - if not self.get_client_config: - raise Exception("Client not ready!") - return self.get_client_config() - - async def _send_msg(self, data: List): - await self.websocket.send_text(json.dumps(data)) - - async def _handle_delete_event(self, event: NostrEvent): - # NIP 09 - filter = NostrFilter(authors=[event.pubkey]) - filter.ids = [t[1] for t in event.tags if t[0] == "e"] - events_to_delete = await get_events(self.relay_id, filter, False) - ids = [e.id for e in events_to_delete if not e.is_delete_event] - await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) - - async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: - if not self.pubkey and self.config.require_auth_filter: - return [["AUTH", self._current_auth_challenge()]] - - filter.subscription_id = subscription_id - self._remove_filter(subscription_id) - if self._can_add_filter(): - return [ - [ - "NOTICE", - f"Maximum number of filters ({self.config.max_client_filters}) exceeded.", - ] - ] - - filter.enforce_limit(self.config.limit_per_filter) - self.filters.append(filter) - events = await get_events(self.relay_id, filter) - events = [e for e in events if not self._is_direct_message_for_other(e)] - serialized_events = [ - event.serialize_response(subscription_id) for event in events - ] - resp_nip15 = ["EOSE", subscription_id] - serialized_events.append(resp_nip15) - return serialized_events - - def _remove_filter(self, subscription_id: str): - self.filters = [f for f in self.filters if f.subscription_id != subscription_id] - - def _handle_close(self, subscription_id: str): - self._remove_filter(subscription_id) - - async def _handle_auth(self): - await self._send_msg(["AUTH", self._current_auth_challenge()]) - - def _can_add_filter(self) -> bool: - return ( - self.config.max_client_filters != 0 - and len(self.filters) >= self.config.max_client_filters - ) - - def _auth_challenge_expired(self): - if self._auth_challenge_created_at == 0: - return True - current_time_seconds = round(time.time()) - chanllenge_max_age_seconds = 300 # 5 min - return ( - current_time_seconds - self._auth_challenge_created_at - ) >= chanllenge_max_age_seconds - - def _current_auth_challenge(self): - if self._auth_challenge_expired(): - self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash() - self._auth_challenge_created_at = round(time.time()) - return self._auth_challenge - - diff --git a/tests/test_clients.py b/tests/test_clients.py index dea0302..56d93df 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -7,10 +7,8 @@ from fastapi import WebSocket from loguru import logger from lnbits.extensions.nostrrelay.models import RelaySpec # type: ignore -from lnbits.extensions.nostrrelay.relay.client_manager import ( # type: ignore - NostrClientConnection, - NostrClientManager, -) +from lnbits.extensions.nostrrelay.relay.client_manager import NostrClientManager # type: ignore +from lnbits.extensions.nostrrelay.relay.client_connection import NostrClientConnection # type: ignore from .helpers import get_fixtures From 6be0169ea914f2bbe17ab5b420ca7e3b9e07fe3e Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:13:06 +0200 Subject: [PATCH 055/114] refactor: extract `NostrEvent` --- crud.py | 5 +- models.py | 101 +---------------------------------- relay/client_connection.py | 3 +- relay/client_manager.py | 3 +- relay/event.py | 105 +++++++++++++++++++++++++++++++++++++ relay/event_validator.py | 4 +- tests/test_events.py | 3 +- 7 files changed, 119 insertions(+), 105 deletions(-) create mode 100644 relay/event.py diff --git a/crud.py b/crud.py index 746010e..8eb7aa0 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,11 @@ import json -from typing import Any, List, Optional, Tuple +from typing import List, Optional, Tuple + +from .relay.event import NostrEvent from . import db from .models import ( NostrAccount, - NostrEvent, NostrFilter, NostrRelay, RelayPublicSpec, diff --git a/models.py b/models.py index f59b500..1b3c953 100644 --- a/models.py +++ b/models.py @@ -1,11 +1,10 @@ -import hashlib import json -from enum import Enum from sqlite3 import Row from typing import Any, List, Optional, Tuple from pydantic import BaseModel, Field -from secp256k1 import PublicKey + +from .relay.event import NostrEvent class Spec(BaseModel): @@ -130,102 +129,6 @@ class NostrRelay(BaseModel): "version": "", } - -class NostrEventType(str, Enum): - EVENT = "EVENT" - REQ = "REQ" - CLOSE = "CLOSE" - AUTH = "AUTH" - - -class NostrEvent(BaseModel): - id: str - pubkey: str - created_at: int - kind: int - tags: List[List[str]] = [] - content: str = "" - sig: str - - def serialize(self) -> List: - return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] - - def serialize_json(self) -> str: - e = self.serialize() - return json.dumps(e, separators=(",", ":"), ensure_ascii=False) - - @property - def event_id(self) -> str: - data = self.serialize_json() - id = hashlib.sha256(data.encode()).hexdigest() - return id - - @property - def size_bytes(self) -> int: - s = json.dumps(dict(self), separators=(",", ":"), ensure_ascii=False) - return len(s.encode()) - - @property - def is_replaceable_event(self) -> bool: - return self.kind in [0, 3, 41] or (self.kind >= 10000 and self.kind < 20000) - - @property - def is_auth_response_event(self) -> bool: - return self.kind == 22242 - - @property - def is_direct_message(self) -> bool: - return self.kind == 4 - - @property - def is_delete_event(self) -> bool: - return self.kind == 5 - - @property - def is_regular_event(self) -> bool: - return self.kind >= 1000 and self.kind < 10000 - - @property - def is_ephemeral_event(self) -> bool: - return self.kind >= 20000 and self.kind < 30000 - - - def check_signature(self): - event_id = self.event_id - if self.id != event_id: - raise ValueError( - f"Invalid event id. Expected: '{event_id}' got '{self.id}'" - ) - try: - pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True) - except Exception: - raise ValueError( - f"Invalid public key: '{self.pubkey}' for event '{self.id}'" - ) - - valid_signature = pub_key.schnorr_verify( - bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True - ) - if not valid_signature: - raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'") - - def serialize_response(self, subscription_id): - return [NostrEventType.EVENT, subscription_id, dict(self)] - - def tag_values(self, tag_name: str) -> List[str]: - return [t[1] for t in self.tags if t[0] == tag_name] - - def has_tag_value(self, tag_name: str, tag_value: str) -> bool: - return tag_value in self.tag_values(tag_name) - - def is_direct_message_for_pubkey(self, pubkey: str) -> bool: - return self.is_direct_message and self.has_tag_value("p", pubkey) - - @classmethod - def from_row(cls, row: Row) -> "NostrEvent": - return cls(**dict(row)) - - class NostrFilter(BaseModel): subscription_id: Optional[str] diff --git a/relay/client_connection.py b/relay/client_connection.py index d7672a6..238873a 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -14,7 +14,8 @@ from ..crud import ( get_events, mark_events_deleted, ) -from ..models import NostrEvent, NostrEventType, NostrFilter, RelaySpec +from .event import NostrEvent, NostrEventType +from ..models import NostrFilter, RelaySpec from .event_validator import EventValidator diff --git a/relay/client_manager.py b/relay/client_manager.py index efdc9b4..7f41880 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -1,9 +1,10 @@ from typing import List +from .event import NostrEvent from .client_connection import NostrClientConnection from ..crud import get_config_for_all_active_relays -from ..models import NostrEvent, RelaySpec +from ..models import RelaySpec class NostrClientManager: diff --git a/relay/event.py b/relay/event.py new file mode 100644 index 0000000..f8e97af --- /dev/null +++ b/relay/event.py @@ -0,0 +1,105 @@ +import hashlib +import json +from enum import Enum +from sqlite3 import Row +from typing import List + +from pydantic import BaseModel +from secp256k1 import PublicKey + + + +class NostrEventType(str, Enum): + EVENT = "EVENT" + REQ = "REQ" + CLOSE = "CLOSE" + AUTH = "AUTH" + + +class NostrEvent(BaseModel): + id: str + pubkey: str + created_at: int + kind: int + tags: List[List[str]] = [] + content: str = "" + sig: str + + def serialize(self) -> List: + return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] + + def serialize_json(self) -> str: + e = self.serialize() + return json.dumps(e, separators=(",", ":"), ensure_ascii=False) + + @property + def event_id(self) -> str: + data = self.serialize_json() + id = hashlib.sha256(data.encode()).hexdigest() + return id + + @property + def size_bytes(self) -> int: + s = json.dumps(dict(self), separators=(",", ":"), ensure_ascii=False) + return len(s.encode()) + + @property + def is_replaceable_event(self) -> bool: + return self.kind in [0, 3, 41] or (self.kind >= 10000 and self.kind < 20000) + + @property + def is_auth_response_event(self) -> bool: + return self.kind == 22242 + + @property + def is_direct_message(self) -> bool: + return self.kind == 4 + + @property + def is_delete_event(self) -> bool: + return self.kind == 5 + + @property + def is_regular_event(self) -> bool: + return self.kind >= 1000 and self.kind < 10000 + + @property + def is_ephemeral_event(self) -> bool: + return self.kind >= 20000 and self.kind < 30000 + + + def check_signature(self): + event_id = self.event_id + if self.id != event_id: + raise ValueError( + f"Invalid event id. Expected: '{event_id}' got '{self.id}'" + ) + try: + pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True) + except Exception: + raise ValueError( + f"Invalid public key: '{self.pubkey}' for event '{self.id}'" + ) + + valid_signature = pub_key.schnorr_verify( + bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True + ) + if not valid_signature: + raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'") + + def serialize_response(self, subscription_id): + return [NostrEventType.EVENT, subscription_id, dict(self)] + + def tag_values(self, tag_name: str) -> List[str]: + return [t[1] for t in self.tags if t[0] == tag_name] + + def has_tag_value(self, tag_name: str, tag_value: str) -> bool: + return tag_value in self.tag_values(tag_name) + + def is_direct_message_for_pubkey(self, pubkey: str) -> bool: + return self.is_direct_message and self.has_tag_value("p", pubkey) + + @classmethod + def from_row(cls, row: Row) -> "NostrEvent": + return cls(**dict(row)) + diff --git a/relay/event_validator.py b/relay/event_validator.py index d6ec25e..aaa6cf2 100644 --- a/relay/event_validator.py +++ b/relay/event_validator.py @@ -1,9 +1,11 @@ import time from typing import Callable, Optional, Tuple +from .event import NostrEvent + from ..crud import get_account, get_storage_for_public_key, prune_old_events from ..helpers import extract_domain -from ..models import NostrAccount, NostrEvent, RelaySpec +from ..models import NostrAccount, RelaySpec class EventValidator: diff --git a/tests/test_events.py b/tests/test_events.py index 5b33c90..0a42ab2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -10,7 +10,8 @@ from lnbits.extensions.nostrrelay.crud import ( # type: ignore get_event, get_events, ) -from lnbits.extensions.nostrrelay.models import NostrEvent, NostrFilter # type: ignore +from lnbits.extensions.nostrrelay.models import NostrFilter # type: ignore +from lnbits.extensions.nostrrelay.relay.event import NostrEvent # type: ignore from .helpers import get_fixtures From 2ebc83a286286ca7af6987fc89cbfc66b9a3c212 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:16:24 +0200 Subject: [PATCH 056/114] refactor: extract `NostrFilter` --- crud.py | 12 +--- models.py | 109 ---------------------------------- relay/client_connection.py | 3 +- relay/client_manager.py | 5 +- relay/event.py | 1 - relay/event_validator.py | 3 +- relay/filter.py | 117 +++++++++++++++++++++++++++++++++++++ tests/test_clients.py | 8 ++- tests/test_events.py | 2 +- 9 files changed, 132 insertions(+), 128 deletions(-) create mode 100644 relay/filter.py diff --git a/crud.py b/crud.py index 8eb7aa0..53ffdc4 100644 --- a/crud.py +++ b/crud.py @@ -1,16 +1,10 @@ import json from typing import List, Optional, Tuple -from .relay.event import NostrEvent - from . import db -from .models import ( - NostrAccount, - NostrFilter, - NostrRelay, - RelayPublicSpec, - RelaySpec, -) +from .models import NostrAccount, NostrRelay, RelayPublicSpec, RelaySpec +from .relay.event import NostrEvent +from .relay.filter import NostrFilter ########################## RELAYS #################### diff --git a/models.py b/models.py index 1b3c953..266e35c 100644 --- a/models.py +++ b/models.py @@ -129,115 +129,6 @@ class NostrRelay(BaseModel): "version": "", } -class NostrFilter(BaseModel): - subscription_id: Optional[str] - - ids: List[str] = [] - authors: List[str] = [] - kinds: List[int] = [] - e: List[str] = Field([], alias="#e") - p: List[str] = Field([], alias="#p") - since: Optional[int] - until: Optional[int] - limit: Optional[int] - - def matches(self, e: NostrEvent) -> bool: - # todo: starts with - if len(self.ids) != 0 and e.id not in self.ids: - return False - if len(self.authors) != 0 and e.pubkey not in self.authors: - return False - if len(self.kinds) != 0 and e.kind not in self.kinds: - return False - - if self.since and e.created_at < self.since: - return False - if self.until and self.until > 0 and e.created_at > self.until: - return False - - found_e_tag = self.tag_in_list(e.tags, "e") - found_p_tag = self.tag_in_list(e.tags, "p") - if not found_e_tag or not found_p_tag: - return False - - return True - - def tag_in_list(self, event_tags, tag_name) -> bool: - filter_tags = dict(self).get(tag_name, []) - if len(filter_tags) == 0: - return True - - event_tag_values = [t[1] for t in event_tags if t[0] == tag_name] - - common_tags = [ - event_tag for event_tag in event_tag_values if event_tag in filter_tags - ] - if len(common_tags) == 0: - return False - return True - - def is_empty(self): - return ( - len(self.ids) == 0 - and len(self.authors) == 0 - and len(self.kinds) == 0 - and len(self.e) == 0 - and len(self.p) == 0 - and (not self.since) - and (not self.until) - ) - - def enforce_limit(self, limit: int): - if not self.limit or self.limit > limit: - self.limit = limit - - def to_sql_components( - self, relay_id: str - ) -> Tuple[List[str], List[str], List[Any]]: - inner_joins: List[str] = [] - where = ["deleted=false", "nostrrelay.events.relay_id = ?"] - values: List[Any] = [relay_id] - - if len(self.e): - values += self.e - e_s = ",".join(["?"] * len(self.e)) - inner_joins.append( - "INNER JOIN nostrrelay.event_tags e_tags ON nostrrelay.events.id = e_tags.event_id" - ) - where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')") - - if len(self.p): - values += self.p - p_s = ",".join(["?"] * len(self.p)) - inner_joins.append( - "INNER JOIN nostrrelay.event_tags p_tags ON nostrrelay.events.id = p_tags.event_id" - ) - where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'") - - if len(self.ids) != 0: - ids = ",".join(["?"] * len(self.ids)) - where.append(f"id IN ({ids})") - values += self.ids - - if len(self.authors) != 0: - authors = ",".join(["?"] * len(self.authors)) - where.append(f"pubkey IN ({authors})") - values += self.authors - - if len(self.kinds) != 0: - kinds = ",".join(["?"] * len(self.kinds)) - where.append(f"kind IN ({kinds})") - values += self.kinds - - if self.since: - where.append("created_at >= ?") - values += [self.since] - - if self.until: - where.append("created_at < ?") - values += [self.until] - - return inner_joins, where, values class BuyOrder(BaseModel): diff --git a/relay/client_connection.py b/relay/client_connection.py index 238873a..9a82859 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -14,9 +14,10 @@ from ..crud import ( get_events, mark_events_deleted, ) +from ..models import RelaySpec from .event import NostrEvent, NostrEventType -from ..models import NostrFilter, RelaySpec from .event_validator import EventValidator +from .filter import NostrFilter class NostrClientConnection: diff --git a/relay/client_manager.py b/relay/client_manager.py index 7f41880..bf8e07e 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -1,10 +1,9 @@ from typing import List -from .event import NostrEvent -from .client_connection import NostrClientConnection - from ..crud import get_config_for_all_active_relays from ..models import RelaySpec +from .client_connection import NostrClientConnection +from .event import NostrEvent class NostrClientManager: diff --git a/relay/event.py b/relay/event.py index f8e97af..15b3c02 100644 --- a/relay/event.py +++ b/relay/event.py @@ -8,7 +8,6 @@ from pydantic import BaseModel from secp256k1 import PublicKey - class NostrEventType(str, Enum): EVENT = "EVENT" REQ = "REQ" diff --git a/relay/event_validator.py b/relay/event_validator.py index aaa6cf2..e8e0e9f 100644 --- a/relay/event_validator.py +++ b/relay/event_validator.py @@ -1,11 +1,10 @@ import time from typing import Callable, Optional, Tuple -from .event import NostrEvent - from ..crud import get_account, get_storage_for_public_key, prune_old_events from ..helpers import extract_domain from ..models import NostrAccount, RelaySpec +from .event import NostrEvent class EventValidator: diff --git a/relay/filter.py b/relay/filter.py new file mode 100644 index 0000000..68f740d --- /dev/null +++ b/relay/filter.py @@ -0,0 +1,117 @@ + +from typing import Any, List, Optional, Tuple + +from pydantic import BaseModel, Field + +from .event import NostrEvent + + +class NostrFilter(BaseModel): + subscription_id: Optional[str] + + ids: List[str] = [] + authors: List[str] = [] + kinds: List[int] = [] + e: List[str] = Field([], alias="#e") + p: List[str] = Field([], alias="#p") + since: Optional[int] + until: Optional[int] + limit: Optional[int] + + def matches(self, e: NostrEvent) -> bool: + # todo: starts with + if len(self.ids) != 0 and e.id not in self.ids: + return False + if len(self.authors) != 0 and e.pubkey not in self.authors: + return False + if len(self.kinds) != 0 and e.kind not in self.kinds: + return False + + if self.since and e.created_at < self.since: + return False + if self.until and self.until > 0 and e.created_at > self.until: + return False + + found_e_tag = self.tag_in_list(e.tags, "e") + found_p_tag = self.tag_in_list(e.tags, "p") + if not found_e_tag or not found_p_tag: + return False + + return True + + def tag_in_list(self, event_tags, tag_name) -> bool: + filter_tags = dict(self).get(tag_name, []) + if len(filter_tags) == 0: + return True + + event_tag_values = [t[1] for t in event_tags if t[0] == tag_name] + + common_tags = [ + event_tag for event_tag in event_tag_values if event_tag in filter_tags + ] + if len(common_tags) == 0: + return False + return True + + def is_empty(self): + return ( + len(self.ids) == 0 + and len(self.authors) == 0 + and len(self.kinds) == 0 + and len(self.e) == 0 + and len(self.p) == 0 + and (not self.since) + and (not self.until) + ) + + def enforce_limit(self, limit: int): + if not self.limit or self.limit > limit: + self.limit = limit + + def to_sql_components( + self, relay_id: str + ) -> Tuple[List[str], List[str], List[Any]]: + inner_joins: List[str] = [] + where = ["deleted=false", "nostrrelay.events.relay_id = ?"] + values: List[Any] = [relay_id] + + if len(self.e): + values += self.e + e_s = ",".join(["?"] * len(self.e)) + inner_joins.append( + "INNER JOIN nostrrelay.event_tags e_tags ON nostrrelay.events.id = e_tags.event_id" + ) + where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')") + + if len(self.p): + values += self.p + p_s = ",".join(["?"] * len(self.p)) + inner_joins.append( + "INNER JOIN nostrrelay.event_tags p_tags ON nostrrelay.events.id = p_tags.event_id" + ) + where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'") + + if len(self.ids) != 0: + ids = ",".join(["?"] * len(self.ids)) + where.append(f"id IN ({ids})") + values += self.ids + + if len(self.authors) != 0: + authors = ",".join(["?"] * len(self.authors)) + where.append(f"pubkey IN ({authors})") + values += self.authors + + if len(self.kinds) != 0: + kinds = ",".join(["?"] * len(self.kinds)) + where.append(f"kind IN ({kinds})") + values += self.kinds + + if self.since: + where.append("created_at >= ?") + values += [self.since] + + if self.until: + where.append("created_at < ?") + values += [self.until] + + return inner_joins, where, values diff --git a/tests/test_clients.py b/tests/test_clients.py index 56d93df..61fe83d 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -7,8 +7,12 @@ from fastapi import WebSocket from loguru import logger from lnbits.extensions.nostrrelay.models import RelaySpec # type: ignore -from lnbits.extensions.nostrrelay.relay.client_manager import NostrClientManager # type: ignore -from lnbits.extensions.nostrrelay.relay.client_connection import NostrClientConnection # type: ignore +from lnbits.extensions.nostrrelay.relay.client_connection import ( + NostrClientConnection, # type: ignore +) +from lnbits.extensions.nostrrelay.relay.client_manager import ( + NostrClientManager, # type: ignore +) from .helpers import get_fixtures diff --git a/tests/test_events.py b/tests/test_events.py index 0a42ab2..8e0249e 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -10,8 +10,8 @@ from lnbits.extensions.nostrrelay.crud import ( # type: ignore get_event, get_events, ) -from lnbits.extensions.nostrrelay.models import NostrFilter # type: ignore from lnbits.extensions.nostrrelay.relay.event import NostrEvent # type: ignore +from lnbits.extensions.nostrrelay.relay.filter import NostrFilter # type: ignore from .helpers import get_fixtures From 855812cb8fe999df9c1e11b4ff3ae0a7fd3aff3c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:23:58 +0200 Subject: [PATCH 057/114] refactor: extract `NostrRelay` --- crud.py | 3 +- models.py | 131 +------------------------------------ relay/client_connection.py | 2 +- relay/client_manager.py | 2 +- relay/event_validator.py | 3 +- relay/relay.py | 130 ++++++++++++++++++++++++++++++++++++ tests/test_clients.py | 10 +-- views_api.py | 3 +- 8 files changed, 143 insertions(+), 141 deletions(-) create mode 100644 relay/relay.py diff --git a/crud.py b/crud.py index 53ffdc4..1f965b1 100644 --- a/crud.py +++ b/crud.py @@ -2,9 +2,10 @@ import json from typing import List, Optional, Tuple from . import db -from .models import NostrAccount, NostrRelay, RelayPublicSpec, RelaySpec +from .models import NostrAccount from .relay.event import NostrEvent from .relay.filter import NostrFilter +from .relay.relay import NostrRelay, RelayPublicSpec, RelaySpec ########################## RELAYS #################### diff --git a/models.py b/models.py index 266e35c..c1804af 100644 --- a/models.py +++ b/models.py @@ -1,134 +1,7 @@ -import json from sqlite3 import Row -from typing import Any, List, Optional, Tuple - -from pydantic import BaseModel, Field - -from .relay.event import NostrEvent - - -class Spec(BaseModel): - class Config: - allow_population_by_field_name = True - - -class FilterSpec(Spec): - max_client_filters = Field(0, alias="maxClientFilters") - limit_per_filter = Field(1000, alias="limitPerFilter") - - -class EventSpec(Spec): - max_events_per_hour = Field(0, alias="maxEventsPerHour") - - created_at_days_past = Field(0, alias="createdAtDaysPast") - created_at_hours_past = Field(0, alias="createdAtHoursPast") - created_at_minutes_past = Field(0, alias="createdAtMinutesPast") - created_at_seconds_past = Field(0, alias="createdAtSecondsPast") - - created_at_days_future = Field(0, alias="createdAtDaysFuture") - created_at_hours_future = Field(0, alias="createdAtHoursFuture") - created_at_minutes_future = Field(0, alias="createdAtMinutesFuture") - created_at_seconds_future = Field(0, alias="createdAtSecondsFuture") - - @property - def created_at_in_past(self) -> int: - return ( - self.created_at_days_past * 86400 - + self.created_at_hours_past * 3600 - + self.created_at_minutes_past * 60 - + self.created_at_seconds_past - ) - - @property - def created_at_in_future(self) -> int: - return ( - self.created_at_days_future * 86400 - + self.created_at_hours_future * 3600 - + self.created_at_minutes_future * 60 - + self.created_at_seconds_future - ) - - -class StorageSpec(Spec): - free_storage_value = Field(1, alias="freeStorageValue") - free_storage_unit = Field("MB", alias="freeStorageUnit") - full_storage_action = Field("prune", alias="fullStorageAction") - - @property - def free_storage_bytes_value(self): - value = self.free_storage_value * 1024 - if self.free_storage_unit == "MB": - value *= 1024 - return value - - -class AuthSpec(BaseModel): - require_auth_events = Field(False, alias="requireAuthEvents") - skiped_auth_events = Field([], alias="skipedAuthEvents") - forced_auth_events = Field([], alias="forcedAuthEvents") - require_auth_filter = Field(False, alias="requireAuthFilter") - - def event_requires_auth(self, kind: int) -> bool: - if self.require_auth_events: - return kind not in self.skiped_auth_events - return kind in self.forced_auth_events - - -class PaymentSpec(BaseModel): - is_paid_relay = Field(False, alias="isPaidRelay") - cost_to_join = Field(0, alias="costToJoin") - - storage_cost_value = Field(0, alias="storageCostValue") - storage_cost_unit = Field("MB", alias="storageCostUnit") - - -class WalletSpec(Spec): - wallet = Field("") - - -class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): - domain: str = "" - - @property - def is_read_only_relay(self): - self.free_storage_value == 0 and not self.is_paid_relay - - -class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec): - pass - - -class NostrRelay(BaseModel): - id: str - name: str - description: Optional[str] - pubkey: Optional[str] - contact: Optional[str] - active: bool = False - - config: "RelaySpec" = RelaySpec() - - @property - def is_free_to_join(self): - return not self.config.is_paid_relay or self.config.cost_to_join == 0 - - @classmethod - def from_row(cls, row: Row) -> "NostrRelay": - relay = cls(**dict(row)) - relay.config = RelaySpec(**json.loads(row["meta"])) - return relay - - @classmethod - def info( - cls, - ) -> dict: - return { - "contact": "https://t.me/lnbits", - "supported_nips": [1, 9, 11, 15, 20, 22, 42], - "software": "LNbits", - "version": "", - } +from typing import Optional +from pydantic import BaseModel class BuyOrder(BaseModel): diff --git a/relay/client_connection.py b/relay/client_connection.py index 9a82859..5af1206 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -14,7 +14,7 @@ from ..crud import ( get_events, mark_events_deleted, ) -from ..models import RelaySpec +from .relay import RelaySpec from .event import NostrEvent, NostrEventType from .event_validator import EventValidator from .filter import NostrFilter diff --git a/relay/client_manager.py b/relay/client_manager.py index bf8e07e..33ecd96 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -1,7 +1,7 @@ from typing import List from ..crud import get_config_for_all_active_relays -from ..models import RelaySpec +from .relay import RelaySpec from .client_connection import NostrClientConnection from .event import NostrEvent diff --git a/relay/event_validator.py b/relay/event_validator.py index e8e0e9f..02c1c61 100644 --- a/relay/event_validator.py +++ b/relay/event_validator.py @@ -3,8 +3,9 @@ from typing import Callable, Optional, Tuple from ..crud import get_account, get_storage_for_public_key, prune_old_events from ..helpers import extract_domain -from ..models import NostrAccount, RelaySpec +from ..models import NostrAccount from .event import NostrEvent +from .relay import RelaySpec class EventValidator: diff --git a/relay/relay.py b/relay/relay.py new file mode 100644 index 0000000..2d7e876 --- /dev/null +++ b/relay/relay.py @@ -0,0 +1,130 @@ +import json +from sqlite3 import Row +from typing import Optional + +from pydantic import BaseModel, Field + + + +class Spec(BaseModel): + class Config: + allow_population_by_field_name = True + + +class FilterSpec(Spec): + max_client_filters = Field(0, alias="maxClientFilters") + limit_per_filter = Field(1000, alias="limitPerFilter") + + +class EventSpec(Spec): + max_events_per_hour = Field(0, alias="maxEventsPerHour") + + created_at_days_past = Field(0, alias="createdAtDaysPast") + created_at_hours_past = Field(0, alias="createdAtHoursPast") + created_at_minutes_past = Field(0, alias="createdAtMinutesPast") + created_at_seconds_past = Field(0, alias="createdAtSecondsPast") + + created_at_days_future = Field(0, alias="createdAtDaysFuture") + created_at_hours_future = Field(0, alias="createdAtHoursFuture") + created_at_minutes_future = Field(0, alias="createdAtMinutesFuture") + created_at_seconds_future = Field(0, alias="createdAtSecondsFuture") + + @property + def created_at_in_past(self) -> int: + return ( + self.created_at_days_past * 86400 + + self.created_at_hours_past * 3600 + + self.created_at_minutes_past * 60 + + self.created_at_seconds_past + ) + + @property + def created_at_in_future(self) -> int: + return ( + self.created_at_days_future * 86400 + + self.created_at_hours_future * 3600 + + self.created_at_minutes_future * 60 + + self.created_at_seconds_future + ) + + +class StorageSpec(Spec): + free_storage_value = Field(1, alias="freeStorageValue") + free_storage_unit = Field("MB", alias="freeStorageUnit") + full_storage_action = Field("prune", alias="fullStorageAction") + + @property + def free_storage_bytes_value(self): + value = self.free_storage_value * 1024 + if self.free_storage_unit == "MB": + value *= 1024 + return value + + +class AuthSpec(BaseModel): + require_auth_events = Field(False, alias="requireAuthEvents") + skiped_auth_events = Field([], alias="skipedAuthEvents") + forced_auth_events = Field([], alias="forcedAuthEvents") + require_auth_filter = Field(False, alias="requireAuthFilter") + + def event_requires_auth(self, kind: int) -> bool: + if self.require_auth_events: + return kind not in self.skiped_auth_events + return kind in self.forced_auth_events + + +class PaymentSpec(BaseModel): + is_paid_relay = Field(False, alias="isPaidRelay") + cost_to_join = Field(0, alias="costToJoin") + + storage_cost_value = Field(0, alias="storageCostValue") + storage_cost_unit = Field("MB", alias="storageCostUnit") + + +class WalletSpec(Spec): + wallet = Field("") + + +class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): + domain: str = "" + + @property + def is_read_only_relay(self): + self.free_storage_value == 0 and not self.is_paid_relay + + +class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec): + pass + + +class NostrRelay(BaseModel): + id: str + name: str + description: Optional[str] + pubkey: Optional[str] + contact: Optional[str] + active: bool = False + + config: "RelaySpec" = RelaySpec() + + @property + def is_free_to_join(self): + return not self.config.is_paid_relay or self.config.cost_to_join == 0 + + @classmethod + def from_row(cls, row: Row) -> "NostrRelay": + relay = cls(**dict(row)) + relay.config = RelaySpec(**json.loads(row["meta"])) + return relay + + @classmethod + def info( + cls, + ) -> dict: + return { + "contact": "https://t.me/lnbits", + "supported_nips": [1, 9, 11, 15, 20, 22, 42], + "software": "LNbits", + "version": "", + } + diff --git a/tests/test_clients.py b/tests/test_clients.py index 61fe83d..b26ec26 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,13 +6,9 @@ import pytest from fastapi import WebSocket from loguru import logger -from lnbits.extensions.nostrrelay.models import RelaySpec # type: ignore -from lnbits.extensions.nostrrelay.relay.client_connection import ( - NostrClientConnection, # type: ignore -) -from lnbits.extensions.nostrrelay.relay.client_manager import ( - NostrClientManager, # type: ignore -) +from lnbits.extensions.nostrrelay.relay.relay import RelaySpec # type: ignore +from lnbits.extensions.nostrrelay.relay.client_connection import NostrClientConnection # type: ignore +from lnbits.extensions.nostrrelay.relay.client_manager import NostrClientManager # type: ignore from .helpers import get_fixtures diff --git a/views_api.py b/views_api.py index 4b31a43..d30d523 100644 --- a/views_api.py +++ b/views_api.py @@ -30,8 +30,9 @@ from .crud import ( update_relay, ) from .helpers import extract_domain, normalize_public_key -from .models import BuyOrder, NostrAccount, NostrPartialAccount, NostrRelay +from .models import BuyOrder, NostrAccount, NostrPartialAccount from .relay.client_manager import NostrClientConnection, NostrClientManager +from .relay.relay import NostrRelay client_manager = NostrClientManager() From 230729483c0bd17bd754e01a406d441a28f776e1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:26:00 +0200 Subject: [PATCH 058/114] chore: code format --- relay/client_connection.py | 16 +++++++++------- relay/client_manager.py | 3 +-- relay/event.py | 2 -- relay/event_validator.py | 20 +++++++++----------- relay/filter.py | 1 - relay/relay.py | 2 -- tests/test_clients.py | 8 ++++++-- 7 files changed, 25 insertions(+), 27 deletions(-) diff --git a/relay/client_connection.py b/relay/client_connection.py index 5af1206..92a70ed 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -14,10 +14,10 @@ from ..crud import ( get_events, mark_events_deleted, ) -from .relay import RelaySpec from .event import NostrEvent, NostrEventType from .event_validator import EventValidator from .filter import NostrFilter +from .relay import RelaySpec class NostrClientConnection: @@ -25,7 +25,7 @@ class NostrClientConnection: self.websocket = websocket self.relay_id = relay_id self.filters: List[NostrFilter] = [] - self.pubkey: Optional[str] = None # set if authenticated + self.pubkey: Optional[str] = None # set if authenticated self._auth_challenge: Optional[str] = None self._auth_challenge_created_at = 0 @@ -65,7 +65,6 @@ class NostrClientConnection: setattr(self, "broadcast_event", broadcast_event) setattr(self, "get_client_config", get_client_config) setattr(self.event_validator, "get_client_config", get_client_config) - async def notify_event(self, event: NostrEvent) -> bool: if self._is_direct_message_for_other(event): @@ -80,8 +79,8 @@ class NostrClientConnection: def _is_direct_message_for_other(self, event: NostrEvent) -> bool: """ - Direct messages are not inteded to be boradcast (even if encrypted). - If the server requires AUTH for kind '4' then direct message will be sent only to the intended client. + Direct messages are not inteded to be boradcast (even if encrypted). + If the server requires AUTH for kind '4' then direct message will be sent only to the intended client. """ if not event.is_direct_message: return False @@ -121,7 +120,9 @@ class NostrClientConnection: resp_nip20: List[Any] = ["OK", e.id] if e.is_auth_response_event: - valid, message = self.event_validator.validate_auth_event(e, self._auth_challenge) + valid, message = self.event_validator.validate_auth_event( + e, self._auth_challenge + ) if not valid: resp_nip20 += [valid, message] await self._send_msg(resp_nip20) @@ -148,7 +149,8 @@ class NostrClientConnection: try: if e.is_replaceable_event: await delete_events( - self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at) + self.relay_id, + NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at), ) if not e.is_ephemeral_event: await create_event(self.relay_id, e, self.pubkey) diff --git a/relay/client_manager.py b/relay/client_manager.py index 33ecd96..c2db58d 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -1,9 +1,9 @@ from typing import List from ..crud import get_config_for_all_active_relays -from .relay import RelaySpec from .client_connection import NostrClientConnection from .event import NostrEvent +from .relay import RelaySpec class NostrClientManager: @@ -69,4 +69,3 @@ class NostrClientManager: setattr(client, "get_client_config", get_client_config) client.init_callbacks(self.broadcast_event, get_client_config) - diff --git a/relay/event.py b/relay/event.py index 15b3c02..1a70e2e 100644 --- a/relay/event.py +++ b/relay/event.py @@ -65,7 +65,6 @@ class NostrEvent(BaseModel): @property def is_ephemeral_event(self) -> bool: return self.kind >= 20000 and self.kind < 30000 - def check_signature(self): event_id = self.event_id @@ -101,4 +100,3 @@ class NostrEvent(BaseModel): @classmethod def from_row(cls, row: Row) -> "NostrEvent": return cls(**dict(row)) - diff --git a/relay/event_validator.py b/relay/event_validator.py index 02c1c61..3b620bf 100644 --- a/relay/event_validator.py +++ b/relay/event_validator.py @@ -9,7 +9,6 @@ from .relay import RelaySpec class EventValidator: - def __init__(self, relay_id: str): self.relay_id = relay_id @@ -18,7 +17,9 @@ class EventValidator: self.get_client_config: Optional[Callable[[], RelaySpec]] = None - async def validate_write(self, e: NostrEvent, publisher_pubkey: str) -> Tuple[bool, str]: + async def validate_write( + self, e: NostrEvent, publisher_pubkey: str + ) -> Tuple[bool, str]: valid, message = self._validate_event(e) if not valid: return (valid, message) @@ -32,7 +33,9 @@ class EventValidator: return True, "" - def validate_auth_event(self, e: NostrEvent, auth_challenge: Optional[str]) -> Tuple[bool, str]: + def validate_auth_event( + self, e: NostrEvent, auth_challenge: Optional[str] + ) -> Tuple[bool, str]: valid, message = self._validate_event(e) if not valid: return (valid, message) @@ -91,9 +94,7 @@ class EventValidator: return False, f"This is a paid relay: '{self.relay_id}'" stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey) - total_available_storage = ( - account.storage + self.config.free_storage_bytes_value - ) + total_available_storage = account.storage + self.config.free_storage_bytes_value if (stored_bytes + event_size_bytes) <= total_available_storage: return True, "" @@ -110,7 +111,6 @@ class EventValidator: return True, "" - def _exceeded_max_events_per_hour(self) -> bool: if self.config.max_events_per_hour == 0: return False @@ -122,9 +122,7 @@ class EventValidator: self._last_event_timestamp = current_time self._event_count_per_timestamp = 0 - return ( - self._event_count_per_timestamp > self.config.max_events_per_hour - ) + return self._event_count_per_timestamp > self.config.max_events_per_hour def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]: current_time = round(time.time()) @@ -134,4 +132,4 @@ class EventValidator: if self.config.created_at_in_future != 0: if created_at > (current_time + self.config.created_at_in_future): return False, "created_at is too much into the future" - return True, "" \ No newline at end of file + return True, "" diff --git a/relay/filter.py b/relay/filter.py index 68f740d..f423eff 100644 --- a/relay/filter.py +++ b/relay/filter.py @@ -1,4 +1,3 @@ - from typing import Any, List, Optional, Tuple from pydantic import BaseModel, Field diff --git a/relay/relay.py b/relay/relay.py index 2d7e876..71f2f24 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -5,7 +5,6 @@ from typing import Optional from pydantic import BaseModel, Field - class Spec(BaseModel): class Config: allow_population_by_field_name = True @@ -127,4 +126,3 @@ class NostrRelay(BaseModel): "software": "LNbits", "version": "", } - diff --git a/tests/test_clients.py b/tests/test_clients.py index b26ec26..e79c3bf 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,9 +6,13 @@ import pytest from fastapi import WebSocket from loguru import logger +from lnbits.extensions.nostrrelay.relay.client_connection import ( + NostrClientConnection, # type: ignore +) +from lnbits.extensions.nostrrelay.relay.client_manager import ( + NostrClientManager, # type: ignore +) from lnbits.extensions.nostrrelay.relay.relay import RelaySpec # type: ignore -from lnbits.extensions.nostrrelay.relay.client_connection import NostrClientConnection # type: ignore -from lnbits.extensions.nostrrelay.relay.client_manager import NostrClientManager # type: ignore from .helpers import get_fixtures From 7ec30451301f9ab4cb9a34ee79ae508079201082 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 14:57:31 +0200 Subject: [PATCH 059/114] feat: add extension clean-up endpoint --- __init__.py | 5 ++++- relay/client_manager.py | 4 ++++ tests/test_clients.py | 8 ++++---- views_api.py | 18 +++++++++++++++++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/__init__.py b/__init__.py index 4067bb4..b5d8407 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ import asyncio +from typing import List from fastapi import APIRouter from fastapi.staticfiles import StaticFiles @@ -19,6 +20,7 @@ nostrrelay_static_files = [ } ] +scheduled_tasks: List[asyncio.Task] = [] def nostrrelay_renderer(): return template_renderer(["lnbits/extensions/nostrrelay/templates"]) @@ -31,4 +33,5 @@ from .views_api import * # noqa def nostrrelay_start(): loop = asyncio.get_event_loop() - loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) + task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) + scheduled_tasks.append(task) diff --git a/relay/client_manager.py b/relay/client_manager.py index c2db58d..488a719 100644 --- a/relay/client_manager.py +++ b/relay/client_manager.py @@ -52,6 +52,10 @@ class NostrClientManager: self._clients[relay_id] = [] return self._clients[relay_id] + async def stop(self): + for relay_id in self._active_relays: + await self._stop_clients_for_relay(relay_id) + async def _stop_clients_for_relay(self, relay_id: str): for client in self.clients(relay_id): if client.relay_id == relay_id: diff --git a/tests/test_clients.py b/tests/test_clients.py index e79c3bf..4035c45 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,11 +6,11 @@ import pytest from fastapi import WebSocket from loguru import logger -from lnbits.extensions.nostrrelay.relay.client_connection import ( - NostrClientConnection, # type: ignore +from lnbits.extensions.nostrrelay.relay.client_connection import ( # type: ignore + NostrClientConnection, ) -from lnbits.extensions.nostrrelay.relay.client_manager import ( - NostrClientManager, # type: ignore +from lnbits.extensions.nostrrelay.relay.client_manager import ( # type: ignore + NostrClientManager, ) from lnbits.extensions.nostrrelay.relay.relay import RelaySpec # type: ignore diff --git a/views_api.py b/views_api.py index d30d523..2700378 100644 --- a/views_api.py +++ b/views_api.py @@ -15,7 +15,7 @@ from lnbits.decorators import ( ) from lnbits.helpers import urlsafe_short_hash -from . import nostrrelay_ext +from . import nostrrelay_ext, scheduled_tasks from .crud import ( create_account, create_relay, @@ -283,3 +283,19 @@ async def api_pay_to_join(data: BuyOrder): status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Cannot create invoice for client to join", ) + + +@nostrrelay_ext.delete("/api/v1", status_code=HTTPStatus.OK) +async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)): + for t in scheduled_tasks: + try: + t.cancel() + except Exception as ex: + logger.warning(ex) + + try: + await client_manager.stop() + except Exception as ex: + logger.warning(ex) + + return {"success": True} From 2d4e83667682d919649851e55bf7995cb1401453 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Feb 2023 15:13:37 +0200 Subject: [PATCH 060/114] doc: summary --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f87fd20..8317f2a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # Nostr Relay -## One click and spin up your own Nostr relay. Share with the world, or use privately. +### One click and spin up your own Nostr relay. Share with the world, or use privately. +**Configure**: +- Free Plan: with limitted storage (limit can be changed) +- Paid Plan: `pay to join` and `pay for storage` +- Storage Limit (can buy more) +- Rate Limit +- Filter Limit +- Allow/Block accounts +- Optional Auth for `Events` and `Filters` ## Supported NIPs - [x] **NIP-01**: Basic protocol flow From 729f36e993e23f8e88a0d00f78615580c0c8e933 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 10:29:46 +0200 Subject: [PATCH 061/114] refactor: extract `relay_info_response` --- helpers.py | 12 ++++++++++++ views.py | 12 +++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/helpers.py b/helpers.py index 5f2b065..169c65c 100644 --- a/helpers.py +++ b/helpers.py @@ -1,6 +1,7 @@ from urllib.parse import urlparse from bech32 import bech32_decode, convertbits +from starlette.responses import JSONResponse def normalize_public_key(pubkey: str) -> str: @@ -23,3 +24,14 @@ def normalize_public_key(pubkey: str) -> str: def extract_domain(url: str) -> str: return urlparse(url).netloc + + +def relay_info_response(relay_public_data: dict) -> JSONResponse: + return JSONResponse( + content=relay_public_data, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "GET", + }, + ) \ No newline at end of file diff --git a/views.py b/views.py index 7d11b17..f170afb 100644 --- a/views.py +++ b/views.py @@ -3,13 +3,14 @@ from http import HTTPStatus from fastapi import Depends, Request from fastapi.exceptions import HTTPException from fastapi.templating import Jinja2Templates -from starlette.responses import HTMLResponse, JSONResponse +from starlette.responses import HTMLResponse from lnbits.core.models import User from lnbits.decorators import check_user_exists from . import nostrrelay_ext, nostrrelay_renderer from .crud import get_public_relay +from .helpers import relay_info_response templates = Jinja2Templates(directory="templates") @@ -32,14 +33,7 @@ async def nostrrelay(request: Request, relay_id: str): ) if request.headers.get("accept") == "application/nostr+json": - return JSONResponse( - content=relay_public_data, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Allow-Methods": "GET", - }, - ) + return relay_info_response(relay_public_data) return nostrrelay_renderer().TemplateResponse( "nostrrelay/public.html", {"request": request, "relay": relay_public_data} From 8d316c4887a05cdcb5eb1849df81295f0c362c8f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 10:31:20 +0200 Subject: [PATCH 062/114] feat: add `redirect_paths` --- __init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/__init__.py b/__init__.py index b5d8407..64d30c7 100644 --- a/__init__.py +++ b/__init__.py @@ -20,6 +20,16 @@ nostrrelay_static_files = [ } ] +redirect_paths = [ + { + "from_path": "/", + "redirect_to_path": "/api/v1/relay-info", + "header_filters": [{ + "accept": "application/nostr+json" + }] + } +] + scheduled_tasks: List[asyncio.Task] = [] def nostrrelay_renderer(): From d66184c077ce6232210e54d69ade75f29a2e4aed Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 10:31:42 +0200 Subject: [PATCH 063/114] feat: add `/api/v1/relay-info` endpoint --- views_api.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index 2700378..4f9a3fa 100644 --- a/views_api.py +++ b/views_api.py @@ -5,6 +5,7 @@ from fastapi import Depends, Request, WebSocket from fastapi.exceptions import HTTPException from loguru import logger from pydantic.types import UUID4 +from starlette.responses import JSONResponse from lnbits.core.services import create_invoice from lnbits.decorators import ( @@ -29,7 +30,7 @@ from .crud import ( update_account, update_relay, ) -from .helpers import extract_domain, normalize_public_key +from .helpers import extract_domain, normalize_public_key, relay_info_response from .models import BuyOrder, NostrAccount, NostrPartialAccount from .relay.client_manager import NostrClientConnection, NostrClientManager from .relay.relay import NostrRelay @@ -124,6 +125,18 @@ async def api_get_relays( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Cannot fetch relays", ) +@nostrrelay_ext.get("/api/v1/relay-info") +async def api_get_relay_info( request: Request, +) -> JSONResponse: + + if request.headers.get("accept") == "application/nostr+json": + return relay_info_response({}) + + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Cannot fetch relays info", + ) + @nostrrelay_ext.get("/api/v1/relay/{relay_id}") From ffb017700338b47294d735afeaecf1d17fd37563 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 10:32:08 +0200 Subject: [PATCH 064/114] chore: code format --- README.md | 79 ++-- config.json | 2 +- manifest.json | 14 +- tests/fixture/clients.json | 737 ++++++++++++++++++------------------- tests/fixture/events.json | 433 +++++++++++----------- 5 files changed, 623 insertions(+), 642 deletions(-) diff --git a/README.md b/README.md index 8317f2a..7955288 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # Nostr Relay ### One click and spin up your own Nostr relay. Share with the world, or use privately. + **Configure**: + - Free Plan: with limitted storage (limit can be changed) - Paid Plan: `pay to join` and `pay for storage` - Storage Limit (can buy more) @@ -11,57 +13,61 @@ - Optional Auth for `Events` and `Filters` ## Supported NIPs - - [x] **NIP-01**: Basic protocol flow - - [x] **NIP-02**: Contact List and Petnames - - `kind: 3`: delete past contact lists as soon as the relay receives a new one - - [x] **NIP-04**: Encrypted Direct Message - - if `AUTH` enabled: send only to the intended target - - [x] **NIP-09**: Event Deletion - - [x] **NIP-11**: Relay Information Document - - >**Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) - - [ ] **NIP-12**: Generic Tag Queries - - todo - - [x] **NIP-15**: End of Stored Events Notice - - [x] **NIP-16**: Event Treatment - - [x] Regular Events - - [x] Replaceable Events - - [x] Ephemeral Events - - [x] **NIP-20**: Command Results - - todo: use correct prefixes - - [x] **NIP-22**: Event created_at Limits - - [ ] **NIP-26**: Delegated Event Signing - - not planned - - [x] **NIP-28** Public Chat - - `kind: 41`: handled similar to `kind 0` metadata events - - [ ] **NIP-33**: Parameterized Replaceable Events - - todo - - [ ] **NIP-40**: Expiration Timestamp - - todo - - [x] **NIP-42**: Authentication of clients to relays - - todo: use correct prefix - - [ ] **NIP-50**: Search Capability - - todo +- [x] **NIP-01**: Basic protocol flow +- [x] **NIP-02**: Contact List and Petnames + - `kind: 3`: delete past contact lists as soon as the relay receives a new one +- [x] **NIP-04**: Encrypted Direct Message + - if `AUTH` enabled: send only to the intended target +- [x] **NIP-09**: Event Deletion +- [x] **NIP-11**: Relay Information Document + - > **Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) +- [ ] **NIP-12**: Generic Tag Queries + - todo +- [x] **NIP-15**: End of Stored Events Notice +- [x] **NIP-16**: Event Treatment + - [x] Regular Events + - [x] Replaceable Events + - [x] Ephemeral Events +- [x] **NIP-20**: Command Results + - todo: use correct prefixes +- [x] **NIP-22**: Event created_at Limits +- [ ] **NIP-26**: Delegated Event Signing + - not planned +- [x] **NIP-28** Public Chat + - `kind: 41`: handled similar to `kind 0` metadata events +- [ ] **NIP-33**: Parameterized Replaceable Events + - todo +- [ ] **NIP-40**: Expiration Timestamp + - todo +- [x] **NIP-42**: Authentication of clients to relays + - todo: use correct prefix +- [ ] **NIP-50**: Search Capability + - todo ## Create Relay + Creating a new relay is straightforward. Just click `New Relay` then enter the Relay Info. + > **Note**: admin users can select a relay id. Regular users will be assigned a generated relay id. -The relay can be activated/deactivated. +> The relay can be activated/deactivated. - **New Relay Dialog** - - ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) + - ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png) ## Configure Relay + Find your Relay in the list and click the expand button (`+`) to configure it. ### Relay Info + This tab contains data according to `NIP-11` (Relay Information Document). + > **Note**: the `domain` is added automatically and shoud be corrected manually if needed. This value is used for `NIP-42` (Authentication of clients to relays) - **Relay Info Tab** - ![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png) - ### Payment By default the relay is free to access, but it can be configured to ask for payments. @@ -76,8 +82,7 @@ Click on the Relay ID (or visit `https://{your_domain}/nostrrelay/${relay_id}`) Here the entry and storage fees can be paid. - **Relay Public Page** - - ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) - + - ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png) ### Config @@ -90,14 +95,13 @@ Some configurations are not standard (`NIPs`) but they help control what clients - **Config Tab** - ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png) - ### Accounts Allows the Relay operator to `Block` or `Allow` certain accounts. If an account is `allowed` then it is not required to `pay to join`. -When an account is `blocked` it does not matter if it `paid to join` or if it is `allowed`. +When an account is `blocked` it does not matter if it `paid to join` or if it is `allowed`. - **Accounts Tab** - ![image](https://user-images.githubusercontent.com/2951406/219615500-8ca98580-dc3d-4163-b321-ae9279d47a98.png) @@ -105,6 +109,7 @@ When an account is `blocked` it does not matter if it `paid to join` or if it i ## Development Create Symbolic Link: + ``` ln -s /Users/my-user/git-repos/nostr-relay-extension/ /Users/my-user/git-repos/lnbits/lnbits/extensions/nostrrelay ``` diff --git a/config.json b/config.json index e3de9fa..afc7f67 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "name": "Nostr Relay", "short_description": "One click launch your own relay!", - "tile": "/nostrrelay/static/image/nostrrelay.png", + "tile": "/nostrrelay/static/image/nostrrelay.png", "contributors": ["arcbtc", "DCs"] } diff --git a/manifest.json b/manifest.json index 7a3cc6c..67ed091 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "repos": [ - { - "id": "nostrrelay", - "organisation": "lnbits", - "repository": "nostr-relay-extension" - } - ] + "repos": [ + { + "id": "nostrrelay", + "organisation": "lnbits", + "repository": "nostr-relay-extension" + } + ] } diff --git a/tests/fixture/clients.json b/tests/fixture/clients.json index 865ec19..3ad9378 100644 --- a/tests/fixture/clients.json +++ b/tests/fixture/clients.json @@ -1,384 +1,365 @@ { - "alice": { - "meta": [ - "EVENT", - { - "id": "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09", - "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", - "created_at": 1675332095, - "kind": 0, - "tags": [], - "content": "{\"name\":\"Alice\"}", - "sig": "95c30b6bbc70f3777d2b2b47ae3961e196eae0df72f3ae301ff1009cdabf9c50bb0eb7825891c842fc6ca5cb268342cc486850a6127ab40df871bd3e1fd0b0d7" - } + "alice": { + "meta": [ + "EVENT", + { + "id": "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09", + "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", + "created_at": 1675332095, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Alice\"}", + "sig": "95c30b6bbc70f3777d2b2b47ae3961e196eae0df72f3ae301ff1009cdabf9c50bb0eb7825891c842fc6ca5cb268342cc486850a6127ab40df871bd3e1fd0b0d7" + } + ], + "meta_response": [ + "OK", + "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09", + true, + "" + ], + "meta_update": [ + "EVENT", + { + "id": "2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a", + "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", + "created_at": 1675673494, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Alice\",\"about\":\"Uses Hamstr\"}", + "sig": "938313418d6d8b16b43213b3347c64925cbc1846e4447b4d878be9b865fe4b78f276ac399ea6b0aa81ed88fb18c992f2fae9e4f70c35c49202e576c54a0dc89c" + } + ], + "meta_update_response": [ + "OK", + "2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a", + true, + "" + ], + "post01": [ + "EVENT", + { + "id": "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", + "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", + "created_at": 1675332224, + "kind": 1, + "tags": [], + "content": "Alice - post 01", + "sig": "8d27c9f818ff194b491de1dc7d52d2d26916d87189ed1330315c4ff5509a986c80f34c2202302f8fe246c0b3f4e2f79103c000cbd6ca65bbe3921e14f30cb35b" + } + ], + "post01_response_ok": [ + "OK", + "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", + true, + "" + ], + "post01_response_duplicate": [ + "OK", + "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", + true, + "error: failed to create event" + ], + "post02": [ + "EVENT", + { + "id": "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5", + "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", + "created_at": 1675332284, + "kind": 1, + "tags": [], + "content": "Alice post 02", + "sig": "012fc88407b0cfb967e80d1117acf6cf03410f6810039543d2290eef64e246d82ad130d08814b2564cee68e77dd0e99ea539e7a9751ef2e0914e7d93f345094e" + } + ], + "post02_response_ok": [ + "OK", + "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5", + true, + "" + ], + "subscribe_reactions_to_me": [ + "REQ", + "notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345", + { + "kinds": [1, 7, 6, 4], + "#p": [ + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" ], - "meta_response": [ - "OK", - "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09", - true, - "" + "limit": 400 + } + ], + "direct_message01": [ + "EVENT", + { + "id": "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225", + "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", + "created_at": 1675412967, + "kind": 4, + "tags": [ + [ + "p", + "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" + ] ], - "meta_update": [ - "EVENT", - { - "id": "2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a", - "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", - "created_at": 1675673494, - "kind": 0, - "tags": [], - "content": "{\"name\":\"Alice\",\"about\":\"Uses Hamstr\"}", - "sig": "938313418d6d8b16b43213b3347c64925cbc1846e4447b4d878be9b865fe4b78f276ac399ea6b0aa81ed88fb18c992f2fae9e4f70c35c49202e576c54a0dc89c" - } + "content": "BwstXDkQJAHnLOrWFzBRDHdMoF4hoXSCwgmR+K2uw237yss/i639rpR2iOIYJP4z?iv=5pTRQh6NBKfe1hyhwh2WEw==", + "sig": "5da31b8a51dcc9fc9665db6199084696b705fc415e1be684b82fe39f3cbd271c2d707fd5a532232205a016e99ed1ef12abdacb52d139d7f5746cb693de71e5aa" + } + ], + "direct_message01_response": [ + "OK", + "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225", + true, + "" + ], + "delete_post01": [ + "EVENT", + { + "kind": 5, + "content": "deleted", + "tags": [ + [ + "e", + "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85" + ], + ["e", "mock-id", ""], + [ + "e", + "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea" + ] ], - "meta_update_response": [ - "OK", - "2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a", - true, - "" - ], - "post01": [ - "EVENT", - { - "id": "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", - "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", - "created_at": 1675332224, - "kind": 1, - "tags": [], - "content": "Alice - post 01", - "sig": "8d27c9f818ff194b491de1dc7d52d2d26916d87189ed1330315c4ff5509a986c80f34c2202302f8fe246c0b3f4e2f79103c000cbd6ca65bbe3921e14f30cb35b" - } - ], - "post01_response_ok": [ - "OK", - "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", - true, - "" - ], - "post01_response_duplicate": [ - "OK", - "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", - true, - "error: failed to create event" - ], - "post02": [ - "EVENT", - { - "id": "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5", - "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", - "created_at": 1675332284, - "kind": 1, - "tags": [], - "content": "Alice post 02", - "sig": "012fc88407b0cfb967e80d1117acf6cf03410f6810039543d2290eef64e246d82ad130d08814b2564cee68e77dd0e99ea539e7a9751ef2e0914e7d93f345094e" - } - ], - "post02_response_ok": [ - "OK", - "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5", - true, - "" - ], - "subscribe_reactions_to_me": [ - "REQ", - "notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345", - { - "kinds": [ - 1, - 7, - 6, - 4 - ], - "#p": [ - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ], - "limit": 400 - } - ], - "direct_message01": [ - "EVENT", - { - "id": "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225", - "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", - "created_at": 1675412967, - "kind": 4, - "tags": [ - [ - "p", - "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" - ] - ], - "content": "BwstXDkQJAHnLOrWFzBRDHdMoF4hoXSCwgmR+K2uw237yss/i639rpR2iOIYJP4z?iv=5pTRQh6NBKfe1hyhwh2WEw==", - "sig": "5da31b8a51dcc9fc9665db6199084696b705fc415e1be684b82fe39f3cbd271c2d707fd5a532232205a016e99ed1ef12abdacb52d139d7f5746cb693de71e5aa" - } - ], - "direct_message01_response": [ - "OK", - "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225", - true, - "" - ], - "delete_post01": [ - "EVENT", - { - "kind": 5, - "content": "deleted", - "tags": [ - [ - "e", - "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85" - ], - [ - "e", - "mock-id", - "" - ], - [ - "e", - "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea" - ] - ], - "created_at": 1675427798, - "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", - "id": "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093", - "sig": "8e972ba7f1ce9d11ba5d49fdd48db4a92ea999790eb604e6a7f01868a26a70a8e96e1f9e104d8f77a5aa7f29e94119e33117b4cc8a5ff9e50ec8c23eeccd94e9" - } - ], - "delete_post01_response": [ - "OK", - "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093", - true, - "" - ], - "subscribe_to_bob_contact_list": [ - "REQ", - "contact", - { - "kinds": [ - 3 - ], - "authors": [ - "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" - ] - } + "created_at": 1675427798, + "pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816", + "id": "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093", + "sig": "8e972ba7f1ce9d11ba5d49fdd48db4a92ea999790eb604e6a7f01868a26a70a8e96e1f9e104d8f77a5aa7f29e94119e33117b4cc8a5ff9e50ec8c23eeccd94e9" + } + ], + "delete_post01_response": [ + "OK", + "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093", + true, + "" + ], + "subscribe_to_bob_contact_list": [ + "REQ", + "contact", + { + "kinds": [3], + "authors": [ + "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" ] - }, - "bob": { - "meta": [ - "EVENT", - { - "id": "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675332410, - "kind": 0, - "tags": [], - "content": "{\"name\":\"Bob\"}", - "sig": "52b142eb5bf95e46424d8f146a0efcfd1be35ec2ae446152ccc875bc82eee66bef6df1af9a4456ec8984540ac4e21905544b5291334e2b18a24e534b788b2d81" - } - ], - "meta_response": [ - "OK", - "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5", - true, - "" - ], - "request_meta_alice": [ - "REQ", - "profile", - { - "kinds": [ - 0 - ], - "authors": [ - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ] - } - ], - "request_posts_alice": [ - "REQ", - "sub0", - { - "kinds": [ - 1 - ], - "authors": [ - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ], - "limit": 50 - } - ], - "like_post01": [ - "EVENT", - { - "id": "700da4df9029a049ddecd1c586b778f434afb55e56c3016d94334108e3829db7", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675350162, - "kind": 7, - "tags": [ - [ - "e", - "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85" - ], - [ - "p", - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ] - ], - "content": "\u2764\ufe0f", - "sig": "3522d670f2e28bd63d32184aa9617df360684e5bc4b7c791b53c5401437e1bf91d1d335f016076fdee9afa99046dc9cc06a39738b25ff9a1562ac7321e3dca2e" - } - ], - "like_post02": [ - "EVENT", - { - "id": "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675332450, - "kind": 7, - "tags": [ - [ - "e", - "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5" - ], - [ - "p", - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ] - ], - "content": "\u2764\ufe0f", - "sig": "90fa8093088ed9280277f10a97c41d68d9f51d24254f7b27c28f5d84ac25426f1bfc217bca0c6712a9965164b07db219ee7e583b94c4d26f00aee87344c3f17a" - } - ], - "like_post02_response": [ - "OK", - "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9", - true, - "" - ], - "comment_on_alice_post01": [ - "EVENT", - { - "id": "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675332468, - "kind": 1, - "tags": [ - [ - "e", - "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85" - ], - [ - "p", - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ] - ], - "content": "bob comment 01", - "sig": "f9bb53e2adc27f3a49ec42d681833742e28d734327107ebba3076be226340503048116947a75274e5262fa03aa0430da6fe697e46e19342639ef208e5690d8c5" - } - ], - "comment_on_alice_post01_response": [ - "OK", - "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea", - true, - "" - ], - "direct_message01": [ - "EVENT", - { - "id": "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675412687, - "kind": 4, - "tags": [ - [ - "p", - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ] - ], - "content": "jjBSyp36t555ywERY2fI4A==?iv=+8bg2vsltrXewAywxw9m6w==", - "sig": "091f4e8e5c497099bfe6af58126e022bc8babe648b8157c39f51e5d3906bfddf01f2f6d1a3ed36f94fbf07b009008fd448fbb8ce35b60260517aa0124a6c5c39" - } - ], - "direct_message01_response": [ - "OK", - "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2", - true, - "" - ], - "subscribe_to_direct_messages": [ - "REQ", - "notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed", - { - "kinds": [ - 4 - ], - "#p": [ - "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" - ], - "limit": 400 - } - ], - "subscribe_to_delete_from_alice": [ - "REQ", - "notifications:delete", - { - "kinds": [ - 5 - ], - "authors": [ - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ], - "limit": 400 - } - ], - "contact_list_create": [ - "EVENT", - { - "id": "141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675350109, - "kind": 3, - "tags": [ - [ - "p", - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ] - ], - "content": "", - "sig": "740972ce0335fe6be7194c995e407e440b4194e49ee2775a19dc36eb5e9d8302ea8d0ab93cdc11eb345a9f8bae32c14bcbd4b7f3fe9b97d197b8426dba139847" - } - ], - "contact_list_create_response": [ - "OK", - "141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62", - true, - "" - ], - "contact_list_update": [ - "EVENT", - { - "id": "1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40", - "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", - "created_at": 1675444161, - "kind": 3, - "tags": [ - [ - "p", - "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" - ], - [ - "p", - "8d21cd7c3f204cbb8aaf7708445b49e6cef7da23a550f9a27d21b1122c0cb4e9" - ] - ], - "content": "", - "sig": "648230464f6b79063da76c1c9d06cd290c65f95fca4bac2e055f84f003847a4b9a2e144b4d77ec9f2f5289d477353a21494548d1b1fbf8795602c8914a062d50" - } - ], - "contact_list_update_response": [ - "OK", - "1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40", - true, - "" + } + ] + }, + "bob": { + "meta": [ + "EVENT", + { + "id": "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675332410, + "kind": 0, + "tags": [], + "content": "{\"name\":\"Bob\"}", + "sig": "52b142eb5bf95e46424d8f146a0efcfd1be35ec2ae446152ccc875bc82eee66bef6df1af9a4456ec8984540ac4e21905544b5291334e2b18a24e534b788b2d81" + } + ], + "meta_response": [ + "OK", + "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5", + true, + "" + ], + "request_meta_alice": [ + "REQ", + "profile", + { + "kinds": [0], + "authors": [ + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" ] - } -} \ No newline at end of file + } + ], + "request_posts_alice": [ + "REQ", + "sub0", + { + "kinds": [1], + "authors": [ + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ], + "limit": 50 + } + ], + "like_post01": [ + "EVENT", + { + "id": "700da4df9029a049ddecd1c586b778f434afb55e56c3016d94334108e3829db7", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675350162, + "kind": 7, + "tags": [ + [ + "e", + "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85" + ], + [ + "p", + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ] + ], + "content": "\u2764\ufe0f", + "sig": "3522d670f2e28bd63d32184aa9617df360684e5bc4b7c791b53c5401437e1bf91d1d335f016076fdee9afa99046dc9cc06a39738b25ff9a1562ac7321e3dca2e" + } + ], + "like_post02": [ + "EVENT", + { + "id": "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675332450, + "kind": 7, + "tags": [ + [ + "e", + "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5" + ], + [ + "p", + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ] + ], + "content": "\u2764\ufe0f", + "sig": "90fa8093088ed9280277f10a97c41d68d9f51d24254f7b27c28f5d84ac25426f1bfc217bca0c6712a9965164b07db219ee7e583b94c4d26f00aee87344c3f17a" + } + ], + "like_post02_response": [ + "OK", + "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9", + true, + "" + ], + "comment_on_alice_post01": [ + "EVENT", + { + "id": "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675332468, + "kind": 1, + "tags": [ + [ + "e", + "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85" + ], + [ + "p", + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ] + ], + "content": "bob comment 01", + "sig": "f9bb53e2adc27f3a49ec42d681833742e28d734327107ebba3076be226340503048116947a75274e5262fa03aa0430da6fe697e46e19342639ef208e5690d8c5" + } + ], + "comment_on_alice_post01_response": [ + "OK", + "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea", + true, + "" + ], + "direct_message01": [ + "EVENT", + { + "id": "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675412687, + "kind": 4, + "tags": [ + [ + "p", + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ] + ], + "content": "jjBSyp36t555ywERY2fI4A==?iv=+8bg2vsltrXewAywxw9m6w==", + "sig": "091f4e8e5c497099bfe6af58126e022bc8babe648b8157c39f51e5d3906bfddf01f2f6d1a3ed36f94fbf07b009008fd448fbb8ce35b60260517aa0124a6c5c39" + } + ], + "direct_message01_response": [ + "OK", + "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2", + true, + "" + ], + "subscribe_to_direct_messages": [ + "REQ", + "notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed", + { + "kinds": [4], + "#p": [ + "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" + ], + "limit": 400 + } + ], + "subscribe_to_delete_from_alice": [ + "REQ", + "notifications:delete", + { + "kinds": [5], + "authors": [ + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ], + "limit": 400 + } + ], + "contact_list_create": [ + "EVENT", + { + "id": "141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675350109, + "kind": 3, + "tags": [ + [ + "p", + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ] + ], + "content": "", + "sig": "740972ce0335fe6be7194c995e407e440b4194e49ee2775a19dc36eb5e9d8302ea8d0ab93cdc11eb345a9f8bae32c14bcbd4b7f3fe9b97d197b8426dba139847" + } + ], + "contact_list_create_response": [ + "OK", + "141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62", + true, + "" + ], + "contact_list_update": [ + "EVENT", + { + "id": "1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40", + "pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a", + "created_at": 1675444161, + "kind": 3, + "tags": [ + [ + "p", + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ], + [ + "p", + "8d21cd7c3f204cbb8aaf7708445b49e6cef7da23a550f9a27d21b1122c0cb4e9" + ] + ], + "content": "", + "sig": "648230464f6b79063da76c1c9d06cd290c65f95fca4bac2e055f84f003847a4b9a2e144b4d77ec9f2f5289d477353a21494548d1b1fbf8795602c8914a062d50" + } + ], + "contact_list_update_response": [ + "OK", + "1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40", + true, + "" + ] + } +} diff --git a/tests/fixture/events.json b/tests/fixture/events.json index 29720f1..a9a2903 100644 --- a/tests/fixture/events.json +++ b/tests/fixture/events.json @@ -1,220 +1,215 @@ { - "valid": [ - { - "name": "kind 0, metadata", - "data": { - "id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6", - "pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491", - "created_at": 1675242172, - "kind": 0, - "tags": [], - "content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}", - "sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f" - } - }, - { - "name": "kind 1, no tags", - "data": { - "kind": 1, - "content": "i126", - "tags": [], - "created_at": 1675239988, - "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", - "id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96", - "sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1" - } - }, - { - "name": "kind 1, reply, e & p tags", - "data": { - "kind": 1, - "content": "i126 reply", - "tags": [ - [ - "e", - "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96", - "", - "root" - ], - [ - "p", - "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" - ] - ], - "created_at": 1675240147, - "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", - "id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894", - "sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957" - } - }, - { - "name": "kind 3, contact list", - "data": { - "id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1", - "pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718", - "created_at": 1675095502, - "kind": 3, - "tags": [ - [ - "p", - "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" - ] - ], - "content": "", - "sig": "591cf6fd40c6fa6ed0b4ef47e22e52577f786a87aafcd293582076cb3ff75a9598f973fe93de833bb5a793bb3c756a853eab884323257207b2df7d217fabf9e9" - } - }, - { - "name": "kind 3, relays", - "data": { - "id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e", - "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", - "created_at": 1675175242, - "kind": 3, - "tags": [ - [ - "p", - "4b1b856e263836ef4e2ffc439f49b5f0f7b7c4bfc6fba79019ea5f0f648c55d5" - ], - [ - "p", - "ba6dbec940142c806e5eebe02863968d2037ef50af33fd43b82309165eed1e2a" - ], - [ - "p", - "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491" - ], - [ - "p", - "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718" - ] - ], - "content": "{\"wss://lnbits.link/nostrrelay/client\":{\"read\":true,\"write\":true}}", - "sig": "279940c52322467abcfcc10a9123f6e25542a40bc7751fef4b4941de1d5382f2bee7e0fc48a744efc4c227609d619009a0ab4557b36b35ec6df8f71e2e384b3a" - } - }, - { - "name": "kind 4, direct message", - "data": { - "kind": 4, - "content": "gw8BFFM6anxgv77elHM5RQ==?iv=w1Qq4gPS3EZ4Csn1NfEgXg==", - "tags": [ - [ - "p", - "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" - ] - ], - "created_at": 1675240247, - "pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e", - "id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5", - "sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d" - } - }, - { - "name": "kind 5, delete message", - "data": { - "kind": 5, - "content": "deleted", - "tags": [ - [ - "e", - "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96" - ] - ], - "created_at": 1675241034, - "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", - "id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1", - "sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312" - } - }, - { - "name": "kind 6, mention (?)", - "data": { - "kind": 6, - "tags": [ - [ - "e", - "201eaebc2a3176eefa488558749a7978b5189794550c58aff885c2d362917bda", - "", - "mention" - ], - [ - "p", - "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" - ] - ], - "content": "#[0]", - "created_at": 1675240471, - "pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e", - "id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e", - "sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e" - } - }, - { - "name": "kind 7, reaction", - "data": { - "kind": 7, - "content": "+", - "tags": [ - [ - "e", - "8dacb8a9326d1b8e055386ba7f1ddf9df1cc0dd90ffe3d15802955227c311c14" - ], - [ - "p", - "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" - ] - ], - "created_at": 1675240377, - "pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e", - "id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da", - "sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947" - } - }, - { - "name": "kind 30,000, replaceable events, 'd' tag", - "data": { - "kind": 30000, - "tags": [ - [ - "d", - "chats/null/lastOpened" - ] - ], - "content": "1675242945", - "created_at": 1675242945, - "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", - "id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1", - "sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8" - } - } - ], - "invalid": [ - { - "name": "invalid event id", - "exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'", - "data": { - "id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa", - "pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491", - "created_at": 1675242172, - "kind": 0, - "tags": [], - "content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}", - "sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f" - } - }, - { - "name": "invalid signature", - "exception": "Invalid signature: 'b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa' for event '3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96'", - "data": { - "kind": 1, - "content": "i126", - "tags": [], - "created_at": 1675239988, - "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", - "id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96", - "sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa" - } - } - ] -} \ No newline at end of file + "valid": [ + { + "name": "kind 0, metadata", + "data": { + "id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6", + "pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491", + "created_at": 1675242172, + "kind": 0, + "tags": [], + "content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}", + "sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f" + } + }, + { + "name": "kind 1, no tags", + "data": { + "kind": 1, + "content": "i126", + "tags": [], + "created_at": 1675239988, + "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", + "id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96", + "sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1" + } + }, + { + "name": "kind 1, reply, e & p tags", + "data": { + "kind": 1, + "content": "i126 reply", + "tags": [ + [ + "e", + "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96", + "", + "root" + ], + [ + "p", + "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" + ] + ], + "created_at": 1675240147, + "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", + "id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894", + "sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957" + } + }, + { + "name": "kind 3, contact list", + "data": { + "id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1", + "pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718", + "created_at": 1675095502, + "kind": 3, + "tags": [ + [ + "p", + "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" + ] + ], + "content": "", + "sig": "591cf6fd40c6fa6ed0b4ef47e22e52577f786a87aafcd293582076cb3ff75a9598f973fe93de833bb5a793bb3c756a853eab884323257207b2df7d217fabf9e9" + } + }, + { + "name": "kind 3, relays", + "data": { + "id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e", + "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", + "created_at": 1675175242, + "kind": 3, + "tags": [ + [ + "p", + "4b1b856e263836ef4e2ffc439f49b5f0f7b7c4bfc6fba79019ea5f0f648c55d5" + ], + [ + "p", + "ba6dbec940142c806e5eebe02863968d2037ef50af33fd43b82309165eed1e2a" + ], + [ + "p", + "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491" + ], + [ + "p", + "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718" + ] + ], + "content": "{\"wss://lnbits.link/nostrrelay/client\":{\"read\":true,\"write\":true}}", + "sig": "279940c52322467abcfcc10a9123f6e25542a40bc7751fef4b4941de1d5382f2bee7e0fc48a744efc4c227609d619009a0ab4557b36b35ec6df8f71e2e384b3a" + } + }, + { + "name": "kind 4, direct message", + "data": { + "kind": 4, + "content": "gw8BFFM6anxgv77elHM5RQ==?iv=w1Qq4gPS3EZ4Csn1NfEgXg==", + "tags": [ + [ + "p", + "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" + ] + ], + "created_at": 1675240247, + "pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e", + "id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5", + "sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d" + } + }, + { + "name": "kind 5, delete message", + "data": { + "kind": 5, + "content": "deleted", + "tags": [ + [ + "e", + "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96" + ] + ], + "created_at": 1675241034, + "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", + "id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1", + "sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312" + } + }, + { + "name": "kind 6, mention (?)", + "data": { + "kind": 6, + "tags": [ + [ + "e", + "201eaebc2a3176eefa488558749a7978b5189794550c58aff885c2d362917bda", + "", + "mention" + ], + [ + "p", + "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" + ] + ], + "content": "#[0]", + "created_at": 1675240471, + "pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e", + "id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e", + "sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e" + } + }, + { + "name": "kind 7, reaction", + "data": { + "kind": 7, + "content": "+", + "tags": [ + [ + "e", + "8dacb8a9326d1b8e055386ba7f1ddf9df1cc0dd90ffe3d15802955227c311c14" + ], + [ + "p", + "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" + ] + ], + "created_at": 1675240377, + "pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e", + "id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da", + "sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947" + } + }, + { + "name": "kind 30,000, replaceable events, 'd' tag", + "data": { + "kind": 30000, + "tags": [["d", "chats/null/lastOpened"]], + "content": "1675242945", + "created_at": 1675242945, + "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", + "id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1", + "sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8" + } + } + ], + "invalid": [ + { + "name": "invalid event id", + "exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'", + "data": { + "id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa", + "pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491", + "created_at": 1675242172, + "kind": 0, + "tags": [], + "content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}", + "sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f" + } + }, + { + "name": "invalid signature", + "exception": "Invalid signature: 'b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa' for event '3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96'", + "data": { + "kind": 1, + "content": "i126", + "tags": [], + "created_at": 1675239988, + "pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211", + "id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96", + "sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] +} From abe8c65c4c3b6cf2051efa081c2877f4de061c43 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 15:48:02 +0200 Subject: [PATCH 065/114] refactor: rename fields --- __init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 64d30c7..e3fa391 100644 --- a/__init__.py +++ b/__init__.py @@ -20,13 +20,13 @@ nostrrelay_static_files = [ } ] -redirect_paths = [ +nostrrelay_redirect_paths = [ { - "from_path": "/", + "from_path": "/nostr", "redirect_to_path": "/api/v1/relay-info", - "header_filters": [{ + "header_filters": { "accept": "application/nostr+json" - }] + } } ] From 8c860f851a5ae4d88ace5c425987c6243c2a0bcf Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 16:09:37 +0200 Subject: [PATCH 066/114] feat: update supported NIPs --- relay/relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/relay.py b/relay/relay.py index 71f2f24..6109769 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -122,7 +122,7 @@ class NostrRelay(BaseModel): ) -> dict: return { "contact": "https://t.me/lnbits", - "supported_nips": [1, 9, 11, 15, 20, 22, 42], + "supported_nips": [1, 2, 4, 9, 11, 15, 16, 20, 22, 28, 42], "software": "LNbits", "version": "", } From f4d47062378e95c317ce3a3b46e33d4f4ea032f3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 16:11:09 +0200 Subject: [PATCH 067/114] chore: remove test path --- __init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index e3fa391..97dc8bd 100644 --- a/__init__.py +++ b/__init__.py @@ -22,7 +22,7 @@ nostrrelay_static_files = [ nostrrelay_redirect_paths = [ { - "from_path": "/nostr", + "from_path": "/", "redirect_to_path": "/api/v1/relay-info", "header_filters": { "accept": "application/nostr+json" From dd9dbbe818570c1e5087463105e467d524f2ae19 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Feb 2023 16:14:18 +0200 Subject: [PATCH 068/114] fix: return generic Relay Info --- views_api.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/views_api.py b/views_api.py index 4f9a3fa..1620a3d 100644 --- a/views_api.py +++ b/views_api.py @@ -126,16 +126,8 @@ async def api_get_relays( detail="Cannot fetch relays", ) @nostrrelay_ext.get("/api/v1/relay-info") -async def api_get_relay_info( request: Request, -) -> JSONResponse: - - if request.headers.get("accept") == "application/nostr+json": - return relay_info_response({}) - - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Cannot fetch relays info", - ) +async def api_get_relay_info() -> JSONResponse: + return relay_info_response(NostrRelay.info()) From 8df0dc2f52c5e2a5c1bb1a531b0de045ecc15756 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 22 Feb 2023 11:38:33 +0200 Subject: [PATCH 069/114] chore: code clean-up --- static/components/relay-details/relay-details.js | 3 --- static/js/index.js | 5 ----- templates/nostrrelay/public.html | 2 -- 3 files changed, 10 deletions(-) diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 3c8111f..0ab78b1 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -132,7 +132,6 @@ async function relayDetails(path) { ) this.relay = data - console.log('### this.relay', this.relay) } catch (error) { LNbits.utils.notifyApiError(error) } @@ -168,8 +167,6 @@ async function relayDetails(path) { this.inkey ) this.accounts = data - - console.log('### this.accounts', this.accounts) } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/static/js/index.js b/static/js/index.js index 950edd1..0845fc1 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -100,9 +100,7 @@ const relays = async () => { this.relayLinks.find(old => old.id === c.id) ) ) - console.log('### relayLinks', this.relayLinks) } catch (error) { - console.log('### getRelays', error) LNbits.utils.notifyApiError(error) } }, @@ -123,7 +121,6 @@ const relays = async () => { } }, showToggleRelayDialog: function (relay) { - console.log('### showToggleRelayDialog', relay) if (relay.active) { this.toggleRelay(relay) return @@ -134,12 +131,10 @@ const relays = async () => { this.toggleRelay(relay) }) .onCancel(async () => { - console.log('#### onCancel') relay.active = !relay.active }) }, toggleRelay: async function (relay) { - console.log('### toggleRelay', relay) try { await LNbits.api.request( 'PUT', diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 29207a5..7c19329 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -237,7 +237,6 @@ '', reqData ) - console.log('### data.invoice', data.invoice) this.invoice = data.invoice } catch (error) { LNbits.utils.notifyApiError(error) @@ -245,7 +244,6 @@ } }, created: function () { - console.log('### created', this.relay) } }) From 16b9d93dca399e062c582b0425784f09221c673a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 22 Feb 2023 11:38:52 +0200 Subject: [PATCH 070/114] fix: use `Spec` for `PaymentSpec` --- relay/relay.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/relay/relay.py b/relay/relay.py index 6109769..82320f9 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -60,7 +60,7 @@ class StorageSpec(Spec): return value -class AuthSpec(BaseModel): +class AuthSpec(Spec): require_auth_events = Field(False, alias="requireAuthEvents") skiped_auth_events = Field([], alias="skipedAuthEvents") forced_auth_events = Field([], alias="forcedAuthEvents") @@ -72,7 +72,7 @@ class AuthSpec(BaseModel): return kind in self.forced_auth_events -class PaymentSpec(BaseModel): +class PaymentSpec(Spec): is_paid_relay = Field(False, alias="isPaidRelay") cost_to_join = Field(0, alias="costToJoin") From 5e313c02822aed35f74ff87e2249af39e00cbc46 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 15 Mar 2023 21:17:51 +0200 Subject: [PATCH 071/114] chore: code format --- static/components/relay-details/relay-details.js | 1 - templates/nostrrelay/public.html | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 0ab78b1..21a3775 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -131,7 +131,6 @@ async function relayDetails(path) { this.inkey ) this.relay = data - } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 7c19329..d300cb7 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -243,8 +243,7 @@ } } }, - created: function () { - } + created: function () {} }) {% endblock %} From 63be2b5b2df51bff3096bfab47083724770901ee Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 24 Feb 2023 15:10:58 +0200 Subject: [PATCH 072/114] feat: show `wss` URL --- .../relay-details/relay-details.html | 20 +++++++++++++++++++ .../components/relay-details/relay-details.js | 9 ++++++++- static/js/index.js | 6 +----- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 2deeeb5..8ea8394 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -68,6 +68,26 @@
+
+
Web Socket Link:
+
+ +
+
+ Copy +
+
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 21a3775..37616b9 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -92,6 +92,13 @@ async function relayDetails(path) { {value: 'block', label: 'Block New Events'}, {value: 'prune', label: 'Prune Old Events'} ] + }, + wssLink: function () { + this.relay.config.domain = + this.relay.config.domain || window.location.hostname + return ( + 'wss://' + this.relay.config.domain + '/nostrrelay/' + this.relay.id + ) } }, @@ -138,7 +145,7 @@ async function relayDetails(path) { updateRelay: async function () { try { const {data} = await LNbits.api.request( - 'PUT', + 'PATCH', '/nostrrelay/api/v1/relay/' + this.relayId, this.adminkey, this.relay diff --git a/static/js/index.js b/static/js/index.js index 0845fc1..1b6d317 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -140,11 +140,7 @@ const relays = async () => { 'PUT', '/nostrrelay/api/v1/relay/' + relay.id, this.g.user.wallets[0].adminkey, - { - active: relay.active, - id: relay.id, - name: relay.name - } + {} ) } catch (error) { LNbits.utils.notifyApiError(error) From 67dda89c81540eeaf2b8d43fd8b9ad928ac24820 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 24 Feb 2023 15:11:16 +0200 Subject: [PATCH 073/114] fix: separate relay update from activete/deactivate --- views_api.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index 1620a3d..61f361f 100644 --- a/views_api.py +++ b/views_api.py @@ -76,7 +76,7 @@ async def api_create_relay( ) -@nostrrelay_ext.put("/api/v1/relay/{relay_id}") +@nostrrelay_ext.patch("/api/v1/relay/{relay_id}") async def api_update_relay( relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key) ) -> NostrRelay: @@ -95,6 +95,8 @@ async def api_update_relay( ) updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)}) updated_relay = await update_relay(wallet.wallet.user, updated_relay) + # activate & deactivate have their own endpoint + updated_relay.active = relay.active if updated_relay.active: await client_manager.enable_relay(relay_id, updated_relay.config) @@ -113,6 +115,38 @@ async def api_update_relay( ) +@nostrrelay_ext.put("/api/v1/relay/{relay_id}") +async def api_toggle_relay( + relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> NostrRelay: + + try: + relay = await get_relay(wallet.wallet.user,relay_id) + if not relay: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Relay not found", + ) + relay.active = not relay.active + updated_relay = await update_relay(wallet.wallet.user, relay) + + if relay.active: + await client_manager.enable_relay(relay_id, relay.config) + else: + await client_manager.disable_relay(relay_id) + + return updated_relay + + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot update relay", + ) + + @nostrrelay_ext.get("/api/v1/relay") async def api_get_relays( wallet: WalletTypeInfo = Depends(require_invoice_key), From 6e1b5dd0bb8ade926db266208cbdbd56ec471164 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 24 Feb 2023 15:15:06 +0200 Subject: [PATCH 074/114] fix: copy button --- static/components/relay-details/relay-details.html | 2 +- static/components/relay-details/relay-details.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 8ea8394..a28c110 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -83,7 +83,7 @@ Copy diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 37616b9..41d1a94 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -245,7 +245,17 @@ async function relayDetails(path) { value = +eventKind this.relay.config.forcedAuthEvents = this.relay.config.forcedAuthEvents.filter(e => e !== value) - } + }, + // todo: bad. base.js not present in custom components + copyText: function (text, message, position) { + var notify = this.$q.notify + Quasar.utils.copyToClipboard(text).then(function () { + notify({ + message: message || 'Copied to clipboard!', + position: position || 'bottom' + }) + }) + }, }, created: async function () { From 218b3243471b446c39f4786e19e61d16398699dd Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 24 Feb 2023 16:36:53 +0200 Subject: [PATCH 075/114] chore: code format --- static/components/relay-details/relay-details.html | 7 +------ static/components/relay-details/relay-details.js | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index a28c110..a4e58cc 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -80,12 +80,7 @@ >
- Copy + Copy
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 41d1a94..ad14b3f 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -255,7 +255,7 @@ async function relayDetails(path) { position: position || 'bottom' }) }) - }, + } }, created: async function () { From 30ffcf7f554a2cf8eae7a36f5f569ec34bb49d1d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 24 Feb 2023 16:37:21 +0200 Subject: [PATCH 076/114] fix: show `wss` & invoice generation --- templates/nostrrelay/public.html | 203 +++++++++++++++---------------- views_api.py | 15 ++- 2 files changed, 110 insertions(+), 108 deletions(-) diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index d300cb7..7334568 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -11,43 +11,90 @@

+
+ + +
- Public Key: - +
+
+ Relay Link: +
+
+ +
+
+ Copy +
+
-
+
+
+ Public Key: +
+
+ +
+
+
+ + +
Cost to join:
- - sats +
+ + sats +
- Pay to Join +
+ Pay to Join +
+
+ Free to join + +
- +
+
-
+
Storage cost:
-
+
sats per @@ -56,6 +103,7 @@
-
- - sats +
+
+ + sats +
- Buy storage space +
+ Buy storage space +
+
+ Free storage + +
- This is a free relay +
+ This is a free Nostr Relay +
@@ -118,77 +177,6 @@
-
- - - - - - - - - - GET - /lnurlp/api/v1/links -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<pay_link_object>, ...] -
Curl example
-
-
-
- - - - - - - - - - - - - - - -
-
-
-
@@ -211,11 +199,20 @@ storageCost: function () { if (!this.relay || !this.relay.config.storageCostValue) return 0 return this.unitsToBuy * this.relay.config.storageCostValue + }, + wssLink: function () { + this.relay.config.domain = + this.relay.config.domain || window.location.hostname + return ( + 'wss://' + this.relay.config.domain + '/nostrrelay/' + this.relay.id + ) } }, methods: { createInvoice: async function (action) { + console.log('### action', action) if (!action) return + this.invoice = '' if (!this.pubkey) { this.$q.notify({ timeout: 5000, diff --git a/views_api.py b/views_api.py index 61f361f..347b197 100644 --- a/views_api.py +++ b/views_api.py @@ -283,11 +283,13 @@ async def api_pay_to_join(data: BuyOrder): detail="Relay not found", ) - if data.action == "join" and relay.is_free_to_join: - raise ValueError("Relay is free to join") - + amount = 0 storage_to_buy = 0 - if data.action == "storage": + if data.action == "join": + if relay.is_free_to_join: + raise ValueError("Relay is free to join") + amount = int(relay.config.cost_to_join) + elif data.action == "storage": if relay.config.storage_cost_value == 0: raise ValueError("Relay storage cost is zero. Cannot buy!") if data.units_to_buy == 0: @@ -295,10 +297,13 @@ async def api_pay_to_join(data: BuyOrder): storage_to_buy = data.units_to_buy * relay.config.storage_cost_value * 1024 if relay.config.storage_cost_unit == "MB": storage_to_buy *= 1024 + amount = data.units_to_buy * relay.config.storage_cost_value + else: + raise ValueError(f"Unknown action: '{data.action}'") _, payment_request = await create_invoice( wallet_id=relay.config.wallet, - amount=int(relay.config.cost_to_join), + amount=amount, memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}", extra={ "tag": "nostrrely", From 3aa4875558edb1987ea9f8c9c61f395575327a16 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Mar 2023 15:06:09 +0200 Subject: [PATCH 077/114] chore: code format --- __init__.py | 11 +++++------ helpers.py | 2 +- views_api.py | 7 ++++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/__init__.py b/__init__.py index 97dc8bd..12154d0 100644 --- a/__init__.py +++ b/__init__.py @@ -21,17 +21,16 @@ nostrrelay_static_files = [ ] nostrrelay_redirect_paths = [ - { - "from_path": "/", - "redirect_to_path": "/api/v1/relay-info", - "header_filters": { - "accept": "application/nostr+json" + { + "from_path": "/", + "redirect_to_path": "/api/v1/relay-info", + "header_filters": {"accept": "application/nostr+json"}, } - } ] scheduled_tasks: List[asyncio.Task] = [] + def nostrrelay_renderer(): return template_renderer(["lnbits/extensions/nostrrelay/templates"]) diff --git a/helpers.py b/helpers.py index 169c65c..5466e70 100644 --- a/helpers.py +++ b/helpers.py @@ -34,4 +34,4 @@ def relay_info_response(relay_public_data: dict) -> JSONResponse: "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET", }, - ) \ No newline at end of file + ) diff --git a/views_api.py b/views_api.py index 347b197..9c6557f 100644 --- a/views_api.py +++ b/views_api.py @@ -121,7 +121,7 @@ async def api_toggle_relay( ) -> NostrRelay: try: - relay = await get_relay(wallet.wallet.user,relay_id) + relay = await get_relay(wallet.wallet.user, relay_id) if not relay: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, @@ -159,12 +159,13 @@ async def api_get_relays( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Cannot fetch relays", ) + + @nostrrelay_ext.get("/api/v1/relay-info") async def api_get_relay_info() -> JSONResponse: return relay_info_response(NostrRelay.info()) - @nostrrelay_ext.get("/api/v1/relay/{relay_id}") async def api_get_relay( relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key) @@ -288,7 +289,7 @@ async def api_pay_to_join(data: BuyOrder): if data.action == "join": if relay.is_free_to_join: raise ValueError("Relay is free to join") - amount = int(relay.config.cost_to_join) + amount = int(relay.config.cost_to_join) elif data.action == "storage": if relay.config.storage_cost_value == 0: raise ValueError("Relay storage cost is zero. Cannot buy!") From 527afa0c8cfa73fea3c8d9073d83ba2a12b18b5f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Mar 2023 15:06:59 +0200 Subject: [PATCH 078/114] feat: wait for paid invoce --- tasks.py | 25 +++++++++++++----- templates/nostrrelay/public.html | 44 ++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/tasks.py b/tasks.py index 87b8eed..8b37e1f 100644 --- a/tasks.py +++ b/tasks.py @@ -1,8 +1,10 @@ import asyncio +import json from loguru import logger from lnbits.core.models import Payment +from lnbits.core.services import websocketUpdater from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener @@ -25,27 +27,36 @@ async def on_invoice_paid(payment: Payment): relay_id = payment.extra.get("relay_id") pubkey = payment.extra.get("pubkey") + hash = payment.payment_hash if not relay_id or not pubkey: - logger.warning( - f"Invoice extra data missing for 'relay_id' and 'pubkey'. Payment hash: {payment.payment_hash}" - ) + message = f"Invoice extra data missing for 'relay_id' and 'pubkey'. Payment hash: {hash}" + logger.warning(message) + await websocketUpdater(hash, json.dumps({"success": False, "message": message})) return - if payment.extra.get("action") == "join": + action = payment.extra.get("action") + if action == "join": await invoice_paid_to_join(relay_id, pubkey, payment.amount) + await websocketUpdater(hash, json.dumps({"success": True})) return - if payment.extra.get("action") == "storage": + if action == "storage": storage_to_buy = payment.extra.get("storage_to_buy") if not storage_to_buy: - logger.warning( - f"Invoice extra data missing for 'storage_to_buy'. Payment hash: {payment.payment_hash}" + message = ( + f"Invoice extra data missing for 'storage_to_buy'. Payment hash: {hash}" ) + logger.warning(message) return await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount) + await websocketUpdater(hash, json.dumps({"success": True})) return + await websocketUpdater( + hash, json.dumps({"success": False, "message": f"Bad action name: '{action}'"}) + ) + async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int): try: diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 7334568..9d4a9a0 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -144,8 +144,9 @@ - +
+ +
+
+
+ + +
+
+
+
@@ -192,6 +207,7 @@ relay: JSON.parse('{{relay | tojson | safe}}'), pubkey: '', invoice: '', + invoiceResponse: null, unitsToBuy: 0 } }, @@ -210,7 +226,6 @@ }, methods: { createInvoice: async function (action) { - console.log('### action', action) if (!action) return this.invoice = '' if (!this.pubkey) { @@ -235,9 +250,34 @@ reqData ) this.invoice = data.invoice + const paymentHashTag = decode(data.invoice).data.tags.find( + t => t.description === 'payment_hash' + ) + if (paymentHashTag) { + await this.waitForPaidInvoice(paymentHashTag.value) + } } catch (error) { LNbits.utils.notifyApiError(error) } + }, + waitForPaidInvoice: function (paymentHash) { + try { + const scheme = location.protocol === 'http:' ? 'ws' : 'wss' + const wsUrl = `${scheme}://${document.domain}:${location.port}/api/v1/ws/${paymentHash}` + const wsConnection = new WebSocket(wsUrl) + wsConnection.onmessage = e => { + this.invoiceResponse = JSON.parse(e.data) + this.invoice = null + wsConnection.close() + } + } catch (error) { + this.$q.notify({ + timeout: 5000, + type: 'warning', + message: 'Failed to get invoice status', + caption: `${error}` + }) + } } }, created: function () {} From 28c0947afb78cc6a04a88588ff72a87806ef0b0d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Mar 2023 15:24:17 +0200 Subject: [PATCH 079/114] fix: invoice can have `undefined` tag --- templates/nostrrelay/public.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 9d4a9a0..53eed80 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -251,7 +251,7 @@ ) this.invoice = data.invoice const paymentHashTag = decode(data.invoice).data.tags.find( - t => t.description === 'payment_hash' + t => t && t.description === 'payment_hash' ) if (paymentHashTag) { await this.waitForPaidInvoice(paymentHashTag.value) From f7fb926c5223b23d26efb4674dedddeee0fea71a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 17 Mar 2023 17:24:33 +0200 Subject: [PATCH 080/114] fix: port value --- templates/nostrrelay/public.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 53eed80..98487c5 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -263,7 +263,8 @@ waitForPaidInvoice: function (paymentHash) { try { const scheme = location.protocol === 'http:' ? 'ws' : 'wss' - const wsUrl = `${scheme}://${document.domain}:${location.port}/api/v1/ws/${paymentHash}` + const port = location.port ? `:${location.port}` : '' + const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${paymentHash}` const wsConnection = new WebSocket(wsUrl) wsConnection.onmessage = e => { this.invoiceResponse = JSON.parse(e.data) From f19fb4a18e42eed48bb7f1ff608fedb5a898ebf4 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 6 Apr 2023 16:59:15 +0300 Subject: [PATCH 081/114] fix: delete account --- crud.py | 10 ++++++ .../relay-details/relay-details.html | 18 +++++++--- .../components/relay-details/relay-details.js | 33 +++++++++++++++++++ views_api.py | 28 ++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crud.py b/crud.py index 1f965b1..926cedf 100644 --- a/crud.py +++ b/crud.py @@ -363,6 +363,16 @@ async def update_account(relay_id: str, a: NostrAccount) -> NostrAccount: return a +async def delete_account(relay_id: str, pubkey: str): + await db.execute( + """ + DELETE FROM nostrrelay.accounts + WHERE relay_id = ? AND pubkey = ? + """, + (relay_id, pubkey), + ) + + async def get_account( relay_id: str, pubkey: str, diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index a4e58cc..8896a0d 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -545,7 +545,7 @@ >
Public Key:
-
+
Allow
-
+
Block @@ -613,6 +613,16 @@ >