From 8645bb6d056cd5c0b67b88f2bb5edcb1d3c58b68 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 13:21:07 +0200 Subject: [PATCH 001/195] refactor: extract query builder --- crud.py | 127 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 69 insertions(+), 58 deletions(-) diff --git a/crud.py b/crud.py index 588271b..e148f69 100644 --- a/crud.py +++ b/crud.py @@ -29,7 +29,74 @@ async def create_event(relay_id: str, e: NostrEvent): await create_event_tags(relay_id, e.id, name, value, extra) -async def get_events(relay_id: str, filter: NostrFilter) -> List[NostrEvent]: +async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> List[NostrEvent]: + values, query = build_select_events_query(relay_id, filter) + + rows = await db.fetchall(query, tuple(values)) + + events = [] + for row in rows: + event = NostrEvent.from_row(row) + if include_tags: + event.tags = await get_event_tags(relay_id, event.id) + events.append(event) + + return events + + +async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: + row = await db.fetchone("SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", (relay_id, id,)) + if not row: + return None + + event = NostrEvent.from_row(row) + event.tags = await get_event_tags(relay_id, id) + return event + +async def delete_events(relay_id: str, ids: List[str]) -> None: + ids = ",".join(["?"] * len(ids)) + values = [relay_id] + ids + await db.execute("DELETE FROM FROM nostrrelay.events WHERE relay_id = ? AND id IN ({ids})", tuple(values)) + + +async def create_event_tags( + relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str] +): + await db.execute( + """ + INSERT INTO nostrrelay.event_tags ( + relay_id, + event_id, + name, + value, + extra + ) + VALUES (?, ?, ?, ?, ?) + """, + (relay_id, event_id, tag_name, tag_value, extra_values), + ) + + +async def get_event_tags( + relay_id: str, event_id: str +) -> List[List[str]]: + rows = await db.fetchall( + "SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?", + (relay_id, event_id), + ) + + tags: List[List[str]] = [] + for row in rows: + tag = [row["name"], row["value"]] + extra = row["extra"] + if extra: + tag += json.loads(extra) + tags.append(tag) + + return tags + + +def build_select_events_query(relay_id:str, filter:NostrFilter): values: List[Any] = [relay_id] query = "SELECT id, pubkey, created_at, kind, content, sig FROM nostrrelay.events " @@ -71,60 +138,4 @@ async def get_events(relay_id: str, filter: NostrFilter) -> List[NostrEvent]: query += " ORDER BY created_at DESC" if filter.limit and type(filter.limit) == int and filter.limit > 0: query += f" LIMIT {filter.limit}" - - rows = await db.fetchall(query, tuple(values)) - - events = [] - for row in rows: - event = NostrEvent.from_row(row) - event.tags = await get_event_tags(relay_id, event.id) - events.append(event) - - return events - - -async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: - row = await db.fetchone("SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", (relay_id, id,)) - if not row: - return None - - event = NostrEvent.from_row(row) - event.tags = await get_event_tags(relay_id, id) - return event - - -async def create_event_tags( - relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str] -): - await db.execute( - """ - INSERT INTO nostrrelay.event_tags ( - relay_id, - event_id, - name, - value, - extra - ) - VALUES (?, ?, ?, ?, ?) - """, - (relay_id, event_id, tag_name, tag_value, extra_values), - ) - - -async def get_event_tags( - relay_id: str, event_id: str -) -> List[List[str]]: - rows = await db.fetchall( - "SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?", - (relay_id, event_id), - ) - - tags: List[List[str]] = [] - for row in rows: - tag = [row["name"], row["value"]] - extra = row["extra"] - if extra: - tag += json.loads(extra) - tags.append(tag) - - return tags + return values, query \ No newline at end of file From b0be06580a978bfa90d920efd6998bf37d703709 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 14:26:30 +0200 Subject: [PATCH 002/195] fat: support NIP09 delete event --- crud.py | 14 ++++++++------ migrations.py | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crud.py b/crud.py index e148f69..3ad4cad 100644 --- a/crud.py +++ b/crud.py @@ -39,7 +39,7 @@ async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> event = NostrEvent.from_row(row) if include_tags: event.tags = await get_event_tags(relay_id, event.id) - events.append(event) + events.append(event) return events @@ -53,10 +53,12 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: event.tags = await get_event_tags(relay_id, id) return event -async def delete_events(relay_id: str, ids: List[str]) -> None: - ids = ",".join(["?"] * len(ids)) - values = [relay_id] + ids - await db.execute("DELETE FROM FROM nostrrelay.events WHERE relay_id = ? AND id IN ({ids})", tuple(values)) +async def delete_events(relay_id: str, id_list: List[str] = []): + if len(id_list) == 0: + return None + ids = ",".join(["?"] * len(id_list)) + values = [relay_id] + id_list + await db.execute(f"UPDATE nostrrelay.events SET deleted=true WHERE relay_id = ? AND id IN ({ids})", tuple(values)) async def create_event_tags( @@ -101,7 +103,7 @@ def build_select_events_query(relay_id:str, filter:NostrFilter): query = "SELECT id, pubkey, created_at, kind, content, sig FROM nostrrelay.events " inner_joins = [] - where = ["nostrrelay.events.relay_id = ?"] + where = ["deleted=false AND nostrrelay.events.relay_id = ?"] if len(filter.e): values += filter.e e_s = ",".join(["?"] * len(filter.e)) diff --git a/migrations.py b/migrations.py index e44635e..6f68b22 100644 --- a/migrations.py +++ b/migrations.py @@ -16,6 +16,7 @@ async def m001_initial(db): f""" CREATE TABLE nostrrelay.events ( relay_id TEXT NOT NULL, + deleted BOOLEAN DEFAULT false, id TEXT PRIMARY KEY, pubkey TEXT NOT NULL, created_at {db.big_int} NOT NULL, From 01764c155e36320947b220061b3edafe3d5c79f0 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 15:45:23 +0200 Subject: [PATCH 003/195] feat: add delete post tests --- client_manager.py | 24 +++++++++++------- models.py | 3 +++ tests/fixture/clients.json | 45 ++++++++++++++++++++++++++++++++++ tests/test_clients.py | 50 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/client_manager.py b/client_manager.py index 22c0825..dc11202 100644 --- a/client_manager.py +++ b/client_manager.py @@ -1,11 +1,10 @@ -import asyncio import json -from typing import Any, Callable, List, Union +from typing import Any, Callable, List from fastapi import WebSocket from loguru import logger -from .crud import create_event, get_events +from .crud import create_event, delete_events, get_events from .models import NostrEvent, NostrEventType, NostrFilter @@ -23,8 +22,7 @@ class NostrClientManager: async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): for client in self.clients: if client != source: - sent = await client.notify_event(event) - print("### sent", sent, event.id) + await client.notify_event(event) class NostrClientConnection: @@ -38,13 +36,12 @@ class NostrClientConnection: await self.websocket.accept() while True: json_data = await self.websocket.receive_text() - print('### received', json_data) + print('### received: ', json_data) try: data = json.loads(json_data) resp = await self.__handle_message(data) for r in resp: - print('### sent query', json.dumps(r)) await self.websocket.send_text(json.dumps(r)) except Exception as e: logger.warning(e) @@ -53,7 +50,6 @@ class NostrClientConnection: for filter in self.filters: if filter.matches(event): resp = event.serialize_response(filter.subscription_id) - print('### sent notify', json.dumps(resp)) await self.websocket.send_text(json.dumps(resp)) return True return False @@ -76,18 +72,28 @@ class NostrClientConnection: return [] - async def __handle_event(self, e: "NostrEvent"): + async def __handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["ok", e.id] try: e.check_signature() await create_event("111", e) await self.broadcast_event(self, e) + if e.is_delete_event(): + await self.__delete_event(e) resp_nip20 += [True, ""] except Exception as ex: resp_nip20 += [False, f"error: failed to create event"] await self.websocket.send_text(json.dumps(resp_nip20)) + async def __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("111", filter, False) + ids = [e.id for e in events_to_delete] + await delete_events("111", ids) + async def __handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id self.remove_filter(subscription_id) diff --git a/models.py b/models.py index db757e5..c7ec48f 100644 --- a/models.py +++ b/models.py @@ -48,6 +48,9 @@ class NostrEvent(BaseModel): id = hashlib.sha256(data.encode()).hexdigest() return id + def is_delete_event(self) -> bool: + return self.kind == 5 + def check_signature(self): event_id = self.event_id if self.id != event_id: diff --git a/tests/fixture/clients.json b/tests/fixture/clients.json index e5bfadf..08a056e 100644 --- a/tests/fixture/clients.json +++ b/tests/fixture/clients.json @@ -98,6 +98,38 @@ "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, + "" ] }, "bob": { @@ -254,6 +286,19 @@ ], "limit": 400 } + ], + "subscribe_to_delete_from_alice": [ + "REQ", + "notifications:delete", + { + "kinds": [ + 5 + ], + "authors": [ + "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816" + ], + "limit": 400 + } ] } } \ No newline at end of file diff --git a/tests/test_clients.py b/tests/test_clients.py index 8fd46f7..3af109c 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,5 +1,5 @@ import asyncio -from json import dumps +from json import dumps, loads import pytest from fastapi import WebSocket @@ -56,6 +56,9 @@ async def test_alice_and_bob(): await alice_writes_to_bob(ws_alice, ws_bob) + await alice_deletes_post01__bob_is_notified(ws_alice, ws_bob) + + def init_clients(): client_manager = NostrClientManager() @@ -230,7 +233,7 @@ async def bob_writes_to_alice(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ), "Alice: Wrong direct message received" -async def alice_writes_to_bob(ws_alice, ws_bob): +async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ws_alice.sent_messages.clear() ws_bob.sent_messages.clear() @@ -262,3 +265,46 @@ async def alice_writes_to_bob(ws_alice, ws_bob): assert ws_bob.sent_messages[1] == dumps( ["EOSE", "notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed"] ), "Bob: Received all stored events" + +async def alice_deletes_post01__bob_is_notified(ws_alice: MockWebSocket, ws_bob:MockWebSocket): + ws_bob.sent_messages.clear() + await ws_bob.wire_mock_data(bob["request_posts_alice"]) + await asyncio.sleep(0.1) + assert ( + len(ws_bob.sent_messages) == 3 + ), "Bob: Expected two posts from Alice plus and EOSE" + + ws_alice.sent_messages.clear() + ws_bob.sent_messages.clear() + + await ws_bob.wire_mock_data(bob["subscribe_to_delete_from_alice"]) + await asyncio.sleep(0.1) + await ws_alice.wire_mock_data(alice["delete_post01"]) + await asyncio.sleep(0.1) + + assert ( + len(ws_alice.sent_messages) == 1 + ), "Alice: Expected confirmation for delete post01" + assert ws_alice.sent_messages[0] == dumps( + alice["delete_post01_response"] + ), "Alice: Wrong confirmation for delete post01" + + assert len(ws_bob.sent_messages) == 2, "Bob: Expects 2 messages for delete post01" + assert ws_bob.sent_messages[0] == dumps( + ["EOSE", "notifications:delete"] + ), "Bob: Expect no delete notification on subscribe" + assert loads(ws_bob.sent_messages[1]) == [ + "EVENT", + "notifications:delete", + alice["delete_post01"][1], + ], "Bob: Expect delete notification later on" + + ws_bob.sent_messages.clear() + await ws_bob.wire_mock_data(bob["request_posts_alice"]) + await asyncio.sleep(0.1) + assert ( + len(ws_bob.sent_messages) == 2 + ), "Bob: Expected one posts from Alice plus and EOSE" + + + From 9b9e2623be43919e58e0eb2728fcb9bcd65ff43c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 15:50:29 +0200 Subject: [PATCH 004/195] fix: do not remove `delete` events --- client_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client_manager.py b/client_manager.py index dc11202..f9bd595 100644 --- a/client_manager.py +++ b/client_manager.py @@ -91,7 +91,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("111", filter, False) - ids = [e.id for e in events_to_delete] + ids = [e.id for e in events_to_delete if not e.is_delete_event()] await delete_events("111", ids) async def __handle_request(self, subscription_id: str, filter: NostrFilter) -> List: From cbbabf2ce84f56d4fa0a741c656382704a85cf0c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 16:37:27 +0200 Subject: [PATCH 005/195] feat: partial NIP11 --- models.py | 10 ++++++++++ views_api.py | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/models.py b/models.py index c7ec48f..a5fca94 100644 --- a/models.py +++ b/models.py @@ -20,6 +20,16 @@ class NostrRelay(BaseModel): def from_row(cls, row: Row) -> "NostrRelay": return cls(**dict(row)) +class NostrRelayInfo(BaseModel): + name: Optional[str] + description: Optional[str] + pubkey: Optional[str] + contact: Optional[str] = "https://t.me/lnbits" + supported_nips: List[str] = ["NIP01", "NIP09", "NIP11"] + software: Optional[str] = "LNbist" + version: Optional[str] + + class NostrEventType(str, Enum): EVENT = "EVENT" REQ = "REQ" diff --git a/views_api.py b/views_api.py index 7a86995..60c268c 100644 --- a/views_api.py +++ b/views_api.py @@ -1,10 +1,12 @@ from http import HTTPStatus from fastapi import Query, WebSocket +from fastapi.responses import JSONResponse from loguru import logger from . import nostrrelay_ext from .client_manager import NostrClientConnection, NostrClientManager +from .models import NostrRelayInfo client_manager = NostrClientManager() @@ -20,6 +22,17 @@ async def websocket_endpoint(websocket: WebSocket): client_manager.remove_client(client) +@nostrrelay_ext.get("/client", status_code=HTTPStatus.OK) +async def api_nostrrelay_info(): + headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "GET" + } + info = NostrRelayInfo() + return JSONResponse(content=dict(info), headers=headers) + + @nostrrelay_ext.get("/api/v1/enable", status_code=HTTPStatus.OK) async def api_nostrrelay(enable: bool = Query(True)): return await enable_relay(enable) From a01c392a89cb850b176d44f233f5b8fb8cdbadaa Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 17:12:39 +0200 Subject: [PATCH 006/195] feat: signal `NIP15` support --- models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.py b/models.py index a5fca94..fa27241 100644 --- a/models.py +++ b/models.py @@ -25,7 +25,7 @@ class NostrRelayInfo(BaseModel): description: Optional[str] pubkey: Optional[str] contact: Optional[str] = "https://t.me/lnbits" - supported_nips: List[str] = ["NIP01", "NIP09", "NIP11"] + supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15"] software: Optional[str] = "LNbist" version: Optional[str] From d27ece735e2bc4b3f1272c7b33e389435cfc8aa1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 17:42:10 +0200 Subject: [PATCH 007/195] fix: `NIP20` duplicate event --- client_manager.py | 10 +++++++--- tests/fixture/clients.json | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client_manager.py b/client_manager.py index f9bd595..0e63d5f 100644 --- a/client_manager.py +++ b/client_manager.py @@ -4,7 +4,7 @@ from typing import Any, Callable, List from fastapi import WebSocket from loguru import logger -from .crud import create_event, delete_events, get_events +from .crud import create_event, delete_events, get_event, get_events from .models import NostrEvent, NostrEventType, NostrFilter @@ -81,8 +81,12 @@ class NostrClientConnection: if e.is_delete_event(): await self.__delete_event(e) resp_nip20 += [True, ""] - except Exception as ex: - resp_nip20 += [False, f"error: failed to create event"] + except ValueError: + resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] + except Exception: + event = await get_event("111", e.id) + # todo: handle NIP20 in detail + resp_nip20 += [event != None, f"error: failed to create event"] await self.websocket.send_text(json.dumps(resp_nip20)) diff --git a/tests/fixture/clients.json b/tests/fixture/clients.json index 08a056e..5badd42 100644 --- a/tests/fixture/clients.json +++ b/tests/fixture/clients.json @@ -39,7 +39,7 @@ "post01_response_duplicate": [ "ok", "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", - false, + true, "error: failed to create event" ], "post02": [ From 8640e2c06f1f48c898ae07c597d5348020ae5f56 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 17:43:06 +0200 Subject: [PATCH 008/195] feat: signal `NIP20` support --- models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.py b/models.py index fa27241..b064d6d 100644 --- a/models.py +++ b/models.py @@ -25,7 +25,7 @@ class NostrRelayInfo(BaseModel): description: Optional[str] pubkey: Optional[str] contact: Optional[str] = "https://t.me/lnbits" - supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15"] + supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"] software: Optional[str] = "LNbist" version: Optional[str] From 5ca27ff28a744f4516690ae1b8768e171c06cc11 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Feb 2023 18:17:41 +0200 Subject: [PATCH 009/195] fix: `ok` -> `OK` upper case --- client_manager.py | 2 +- tests/fixture/clients.json | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client_manager.py b/client_manager.py index 0e63d5f..9d31cd8 100644 --- a/client_manager.py +++ b/client_manager.py @@ -73,7 +73,7 @@ class NostrClientConnection: return [] async def __handle_event(self, e: NostrEvent): - resp_nip20: List[Any] = ["ok", e.id] + resp_nip20: List[Any] = ["OK", e.id] try: e.check_signature() await create_event("111", e) diff --git a/tests/fixture/clients.json b/tests/fixture/clients.json index 5badd42..7b9f1df 100644 --- a/tests/fixture/clients.json +++ b/tests/fixture/clients.json @@ -13,7 +13,7 @@ } ], "meta_response": [ - "ok", + "OK", "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09", true, "" @@ -31,13 +31,13 @@ } ], "post01_response_ok": [ - "ok", + "OK", "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", true, "" ], "post01_response_duplicate": [ - "ok", + "OK", "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85", true, "error: failed to create event" @@ -55,7 +55,7 @@ } ], "post02_response_ok": [ - "ok", + "OK", "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5", true, "" @@ -94,7 +94,7 @@ } ], "direct_message01_response": [ - "ok", + "OK", "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225", true, "" @@ -126,7 +126,7 @@ } ], "delete_post01_response": [ - "ok", + "OK", "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093", true, "" @@ -146,7 +146,7 @@ } ], "meta_response": [ - "ok", + "OK", "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5", true, "" @@ -219,7 +219,7 @@ } ], "like_post02_response": [ - "ok", + "OK", "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9", true, "" @@ -246,7 +246,7 @@ } ], "comment_on_alice_post01_response": [ - "ok", + "OK", "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea", true, "" @@ -269,7 +269,7 @@ } ], "direct_message01_response": [ - "ok", + "OK", "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2", true, "" From 282e65954bc4b8e3d6f5322f9ad8f7d21045bf98 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 10:07:00 +0200 Subject: [PATCH 010/195] refactor: query builder --- crud.py | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/crud.py b/crud.py index 3ad4cad..9ec8205 100644 --- a/crud.py +++ b/crud.py @@ -99,11 +99,22 @@ async def get_event_tags( def build_select_events_query(relay_id:str, filter:NostrFilter): + values, where_clause = build_where_clause(relay_id, filter) + query = f""" + SELECT id, pubkey, created_at, kind, content, sig + FROM nostrrelay.events {where_clause} + ORDER BY created_at DESC + """ + + if filter.limit and type(filter.limit) == int and filter.limit > 0: + query += f" LIMIT {filter.limit}" + + return values, query + +def build_where_clause(relay_id:str, filter:NostrFilter): values: List[Any] = [relay_id] - query = "SELECT id, pubkey, created_at, kind, content, sig FROM nostrrelay.events " - inner_joins = [] - where = ["deleted=false AND nostrrelay.events.relay_id = ?"] + where = ["deleted=false", "nostrrelay.events.relay_id = ?"] if len(filter.e): values += filter.e e_s = ",".join(["?"] * len(filter.e)) @@ -115,29 +126,29 @@ def build_select_events_query(relay_id:str, filter:NostrFilter): p_s = ",".join(["?"] * len(filter.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'") - query += " ".join(inner_joins)+ " WHERE " + " AND ".join(where) - if len(filter.ids) != 0: ids = ",".join(["?"] * len(filter.ids)) - query += f" AND id IN ({ids})" + where.append(f"id IN ({ids})") values += filter.ids + if len(filter.authors) != 0: authors = ",".join(["?"] * len(filter.authors)) - query += f" AND pubkey IN ({authors})" + where.append(f"pubkey IN ({authors})") values += filter.authors + if len(filter.kinds) != 0: kinds = ",".join(["?"] * len(filter.kinds)) - query += f" AND kind IN ({kinds})" + where.append(f"kind IN ({kinds})") values += filter.kinds - if filter.since: - query += " AND created_at >= ?" - values += [filter.since] - if filter.until: - query += " AND created_at <= ?" - values += [filter.until] - query += " ORDER BY created_at DESC" - if filter.limit and type(filter.limit) == int and filter.limit > 0: - query += f" LIMIT {filter.limit}" + if filter.since: + where.append("reated_at >= ?") + values += [filter.since] + + if filter.until: + where.append("created_at <= ?") + values += [filter.until] + + query = " ".join(inner_joins)+ " WHERE " + " AND ".join(where) return values, query \ No newline at end of file From 2dfd70c38c45848a97febc53c63fbade4b3616d1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 10:19:47 +0200 Subject: [PATCH 011/195] refactor: query builder --- client_manager.py | 4 ++-- crud.py | 20 ++++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/client_manager.py b/client_manager.py index 9d31cd8..f2ac563 100644 --- a/client_manager.py +++ b/client_manager.py @@ -4,7 +4,7 @@ from typing import Any, Callable, List from fastapi import WebSocket from loguru import logger -from .crud import create_event, delete_events, get_event, get_events +from .crud import create_event, mark_events_deleted, get_event, get_events from .models import NostrEvent, NostrEventType, NostrFilter @@ -96,7 +96,7 @@ class NostrClientConnection: filter.ids = [t[1] for t in event.tags if t[0] == "e"] events_to_delete = await get_events("111", filter, False) ids = [e.id for e in events_to_delete if not e.is_delete_event()] - await delete_events("111", ids) + await mark_events_deleted("111", ids) async def __handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id diff --git a/crud.py b/crud.py index 9ec8205..03ed900 100644 --- a/crud.py +++ b/crud.py @@ -53,7 +53,7 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: event.tags = await get_event_tags(relay_id, id) return event -async def delete_events(relay_id: str, id_list: List[str] = []): +async def mark_events_deleted(relay_id: str, id_list: List[str] = []): if len(id_list) == 0: return None ids = ",".join(["?"] * len(id_list)) @@ -99,22 +99,26 @@ async def get_event_tags( def build_select_events_query(relay_id:str, filter:NostrFilter): - values, where_clause = build_where_clause(relay_id, filter) + inner_joins, where, values = build_where_clause(relay_id, filter) + query = f""" SELECT id, pubkey, created_at, kind, content, sig - FROM nostrrelay.events {where_clause} + FROM nostrrelay.events + {" ".join(inner_joins)} + WHERE { " AND ".join(where)} ORDER BY created_at DESC """ - if filter.limit and type(filter.limit) == int and filter.limit > 0: + if filter.limit and filter.limit > 0: query += f" LIMIT {filter.limit}" return values, query def build_where_clause(relay_id:str, filter:NostrFilter): - values: List[Any] = [relay_id] inner_joins = [] where = ["deleted=false", "nostrrelay.events.relay_id = ?"] + values: List[Any] = [relay_id] + if len(filter.e): values += filter.e e_s = ",".join(["?"] * len(filter.e)) @@ -145,10 +149,10 @@ def build_where_clause(relay_id:str, filter:NostrFilter): if filter.since: where.append("reated_at >= ?") values += [filter.since] - + if filter.until: where.append("created_at <= ?") values += [filter.until] - query = " ".join(inner_joins)+ " WHERE " + " AND ".join(where) - return values, query \ No newline at end of file + + return inner_joins, where, values \ No newline at end of file From f01ea6a2371e66ee4d6dd09de3f58e049d55433b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 10:25:28 +0200 Subject: [PATCH 012/195] refactor: delete using filter --- client_manager.py | 2 +- crud.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client_manager.py b/client_manager.py index f2ac563..45c63fe 100644 --- a/client_manager.py +++ b/client_manager.py @@ -96,7 +96,7 @@ class NostrClientConnection: filter.ids = [t[1] for t in event.tags if t[0] == "e"] events_to_delete = await get_events("111", filter, False) ids = [e.id for e in events_to_delete if not e.is_delete_event()] - await mark_events_deleted("111", ids) + await mark_events_deleted("111", NostrFilter(ids=ids)) async def __handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id diff --git a/crud.py b/crud.py index 03ed900..6139fd9 100644 --- a/crud.py +++ b/crud.py @@ -53,11 +53,11 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: event.tags = await get_event_tags(relay_id, id) return event -async def mark_events_deleted(relay_id: str, id_list: List[str] = []): - if len(id_list) == 0: +async def mark_events_deleted(relay_id: str, filter: NostrFilter): + if len(filter.ids) == 0: return None - ids = ",".join(["?"] * len(id_list)) - values = [relay_id] + id_list + ids = ",".join(["?"] * len(filter.ids)) + values = [relay_id] + filter.ids await db.execute(f"UPDATE nostrrelay.events SET deleted=true WHERE relay_id = ? AND id IN ({ids})", tuple(values)) From 57197b981dd4af902084f3241b52966231c208f4 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 10:37:06 +0200 Subject: [PATCH 013/195] refactor: mark delete using query builder --- client_manager.py | 2 +- crud.py | 9 +++++---- models.py | 33 +++++++++++++++++++++++---------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/client_manager.py b/client_manager.py index 45c63fe..a717a48 100644 --- a/client_manager.py +++ b/client_manager.py @@ -4,7 +4,7 @@ from typing import Any, Callable, List from fastapi import WebSocket from loguru import logger -from .crud import create_event, mark_events_deleted, get_event, get_events +from .crud import create_event, get_event, get_events, mark_events_deleted from .models import NostrEvent, NostrEventType, NostrFilter diff --git a/crud.py b/crud.py index 6139fd9..2711aac 100644 --- a/crud.py +++ b/crud.py @@ -54,11 +54,11 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: return event async def mark_events_deleted(relay_id: str, filter: NostrFilter): - if len(filter.ids) == 0: + if filter.is_empty(): return None - ids = ",".join(["?"] * len(filter.ids)) - values = [relay_id] + filter.ids - await db.execute(f"UPDATE nostrrelay.events SET deleted=true WHERE relay_id = ? AND id IN ({ids})", tuple(values)) + _, where, values = build_where_clause(relay_id, filter) + + await db.execute(f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", tuple(values)) async def create_event_tags( @@ -109,6 +109,7 @@ def build_select_events_query(relay_id:str, filter:NostrFilter): ORDER BY created_at DESC """ + # todo: check range if filter.limit and filter.limit > 0: query += f" LIMIT {filter.limit}" diff --git a/models.py b/models.py index b064d6d..9c16c03 100644 --- a/models.py +++ b/models.py @@ -20,14 +20,15 @@ class NostrRelay(BaseModel): def from_row(cls, row: Row) -> "NostrRelay": return cls(**dict(row)) + class NostrRelayInfo(BaseModel): - name: Optional[str] - description: Optional[str] - pubkey: Optional[str] - contact: Optional[str] = "https://t.me/lnbits" - supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"] - software: Optional[str] = "LNbist" - version: Optional[str] + name: Optional[str] + description: Optional[str] + pubkey: Optional[str] + contact: Optional[str] = "https://t.me/lnbits" + supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"] + software: Optional[str] = "LNbist" + version: Optional[str] class NostrEventType(str, Enum): @@ -80,7 +81,6 @@ class NostrEvent(BaseModel): 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)] @@ -128,8 +128,21 @@ class NostrFilter(BaseModel): 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] + + 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) + ) From 10ef9ee2ac4c9a3c6e5f211d778d8e3253e27331 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 11:10:14 +0200 Subject: [PATCH 014/195] feat: on `meta` update, replace old `meta` --- client_manager.py | 17 ++++++++++++----- crud.py | 10 +++++++++- models.py | 3 +++ tests/fixture/clients.json | 18 ++++++++++++++++++ tests/test_clients.py | 23 ++++++++++++++--------- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/client_manager.py b/client_manager.py index a717a48..d149884 100644 --- a/client_manager.py +++ b/client_manager.py @@ -4,7 +4,13 @@ from typing import Any, Callable, List from fastapi import WebSocket from loguru import logger -from .crud import create_event, get_event, get_events, mark_events_deleted +from .crud import ( + create_event, + delete_events, + get_event, + get_events, + mark_events_deleted, +) from .models import NostrEvent, NostrEventType, NostrFilter @@ -36,7 +42,7 @@ class NostrClientConnection: await self.websocket.accept() while True: json_data = await self.websocket.receive_text() - print('### received: ', json_data) + print("### received: ", json_data) try: data = json.loads(json_data) @@ -53,7 +59,6 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(resp)) return True return False - async def __handle_message(self, data: List) -> List: if len(data) < 2: @@ -76,10 +81,12 @@ class NostrClientConnection: resp_nip20: List[Any] = ["OK", e.id] try: e.check_signature() + if e.is_meta_event(): + await delete_events("111", NostrFilter(kinds=[0], authors=[e.pubkey])) await create_event("111", e) await self.broadcast_event(self, e) if e.is_delete_event(): - await self.__delete_event(e) + await self.__handle_delete_event(e) resp_nip20 += [True, ""] except ValueError: resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] @@ -90,7 +97,7 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(resp_nip20)) - async def __delete_event(self, event: NostrEvent): + 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"] diff --git a/crud.py b/crud.py index 2711aac..ed5c619 100644 --- a/crud.py +++ b/crud.py @@ -60,6 +60,14 @@ async def mark_events_deleted(relay_id: str, filter: NostrFilter): await db.execute(f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", tuple(values)) +async def delete_events(relay_id: str, filter: NostrFilter): + if filter.is_empty(): + return None + _, where, values = build_where_clause(relay_id, filter) + + query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}""" + await db.execute(query, tuple(values)) + async def create_event_tags( relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str] @@ -109,7 +117,7 @@ def build_select_events_query(relay_id:str, filter:NostrFilter): ORDER BY created_at DESC """ - # todo: check range + # todo: check & enforce range if filter.limit and filter.limit > 0: query += f" LIMIT {filter.limit}" diff --git a/models.py b/models.py index 9c16c03..b2c2ac3 100644 --- a/models.py +++ b/models.py @@ -59,6 +59,9 @@ class NostrEvent(BaseModel): id = hashlib.sha256(data.encode()).hexdigest() return id + def is_meta_event(self) -> bool: + return self.kind == 0 + def is_delete_event(self) -> bool: return self.kind == 5 diff --git a/tests/fixture/clients.json b/tests/fixture/clients.json index 7b9f1df..7fa2f95 100644 --- a/tests/fixture/clients.json +++ b/tests/fixture/clients.json @@ -18,6 +18,24 @@ 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", { diff --git a/tests/test_clients.py b/tests/test_clients.py index 3af109c..194a006 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,7 +1,8 @@ import asyncio -from json import dumps, loads - import pytest + +from json import dumps, loads +from copy import deepcopy from fastapi import WebSocket from lnbits.extensions.nostrrelay.client_manager import ( @@ -80,10 +81,11 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): await ws_alice.wire_mock_data(alice["meta"]) await ws_alice.wire_mock_data(alice["post01"]) await ws_alice.wire_mock_data(alice["post01"]) + await ws_alice.wire_mock_data(alice["meta_update"]) await asyncio.sleep(0.5) assert ( - len(ws_alice.sent_messages) == 3 + len(ws_alice.sent_messages) == 4 ), "Alice: Expected 3 confirmations to be sent" assert ws_alice.sent_messages[0] == dumps( alice["meta_response"] @@ -94,6 +96,9 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): assert ws_alice.sent_messages[2] == dumps( alice["post01_response_duplicate"] ), "Alice: Expected failure for double posting" + assert ws_alice.sent_messages[3] == dumps( + alice["meta_update_response"] + ), "Alice: Expected confirmation for meta update" await asyncio.sleep(0.1) @@ -112,8 +117,8 @@ async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket): bob["meta_response"] ), "Bob: Wrong confirmation for meta" assert ws_bob.sent_messages[1] == dumps( - ["EVENT", "profile", alice["meta"][1]] - ), "Bob: Wrong response for Alice's meta" + ["EVENT", "profile", alice["meta_update"][1]] + ), "Bob: Wrong response for Alice's meta (updated version)" assert ws_bob.sent_messages[2] == dumps( ["EOSE", "profile"] ), "Bob: Wrong End Of Streaming Event for profile" @@ -266,7 +271,10 @@ async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ["EOSE", "notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed"] ), "Bob: Received all stored events" -async def alice_deletes_post01__bob_is_notified(ws_alice: MockWebSocket, ws_bob:MockWebSocket): + +async def alice_deletes_post01__bob_is_notified( + ws_alice: MockWebSocket, ws_bob: MockWebSocket +): ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["request_posts_alice"]) await asyncio.sleep(0.1) @@ -305,6 +313,3 @@ async def alice_deletes_post01__bob_is_notified(ws_alice: MockWebSocket, ws_bob: assert ( len(ws_bob.sent_messages) == 2 ), "Bob: Expected one posts from Alice plus and EOSE" - - - From 5a747361affe254c8ba7b649626026c84f590bd9 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 12:09:53 +0200 Subject: [PATCH 015/195] feat: make event `kind: 3` replaceable --- client_manager.py | 6 ++-- models.py | 6 ++-- tests/fixture/clients.json | 62 ++++++++++++++++++++++++++++++++++++++ tests/fixture/events.json | 2 +- tests/test_clients.py | 43 ++++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/client_manager.py b/client_manager.py index d149884..31325c9 100644 --- a/client_manager.py +++ b/client_manager.py @@ -81,8 +81,10 @@ class NostrClientConnection: resp_nip20: List[Any] = ["OK", e.id] try: e.check_signature() - if e.is_meta_event(): - await delete_events("111", NostrFilter(kinds=[0], authors=[e.pubkey])) + if e.is_replaceable_event(): + await delete_events( + "111", NostrFilter(kinds=[e.kind], authors=[e.pubkey]) + ) await create_event("111", e) await self.broadcast_event(self, e) if e.is_delete_event(): diff --git a/models.py b/models.py index b2c2ac3..bb1efe5 100644 --- a/models.py +++ b/models.py @@ -59,9 +59,9 @@ class NostrEvent(BaseModel): id = hashlib.sha256(data.encode()).hexdigest() return id - def is_meta_event(self) -> bool: - return self.kind == 0 - + def is_replaceable_event(self) -> bool: + return self.kind in [0, 3] + def is_delete_event(self) -> bool: return self.kind == 5 diff --git a/tests/fixture/clients.json b/tests/fixture/clients.json index 7fa2f95..865ec19 100644 --- a/tests/fixture/clients.json +++ b/tests/fixture/clients.json @@ -148,6 +148,18 @@ "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093", true, "" + ], + "subscribe_to_bob_contact_list": [ + "REQ", + "contact", + { + "kinds": [ + 3 + ], + "authors": [ + "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a" + ] + } ] }, "bob": { @@ -317,6 +329,56 @@ ], "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, + "" ] } } \ No newline at end of file diff --git a/tests/fixture/events.json b/tests/fixture/events.json index 432f949..29720f1 100644 --- a/tests/fixture/events.json +++ b/tests/fixture/events.json @@ -48,7 +48,7 @@ } }, { - "name": "kind 3", + "name": "kind 3, contact list", "data": { "id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1", "pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718", diff --git a/tests/test_clients.py b/tests/test_clients.py index 194a006..49fedf7 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,8 +1,8 @@ import asyncio -import pytest - -from json import dumps, loads from copy import deepcopy +from json import dumps, loads + +import pytest from fastapi import WebSocket from lnbits.extensions.nostrrelay.client_manager import ( @@ -45,6 +45,8 @@ async def test_alice_and_bob(): await bob_wires_meta_and_folows_alice(ws_bob) + await bob_wires_contact_list(ws_alice, ws_bob) + await alice_wires_post02_____bob_is_notified(ws_alice, ws_bob) await bob_likes_post01_____alice_subscribes_and_receives_notifications( @@ -130,6 +132,41 @@ async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket): ), "Bob: Wrong End Of Streaming Event for sub0" +async def bob_wires_contact_list(ws_alice: MockWebSocket, ws_bob: MockWebSocket): + ws_alice.sent_messages.clear() + ws_bob.sent_messages.clear() + + await ws_bob.wire_mock_data(bob["contact_list_create"]) + await ws_bob.wire_mock_data(bob["contact_list_update"]) + await asyncio.sleep(0.1) + await ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"]) + await asyncio.sleep(0.1) + + + print("### ws_alice.sent_message", ws_alice.sent_messages) + print("### ws_bob.sent_message", ws_bob.sent_messages) + + assert ( + len(ws_bob.sent_messages) == 2 + ), "Bob: Expected 1 confirmation for create contact list" + assert ws_bob.sent_messages[0] == dumps( + bob["contact_list_create_response"] + ), "Bob: Wrong confirmation for contact list create" + assert ws_bob.sent_messages[1] == dumps( + bob["contact_list_update_response"] + ), "Bob: Wrong confirmation for contact list update" + + assert ( + len(ws_alice.sent_messages) == 2 + ), "Alice: Expected 3 messages for Bob's contact list" + assert ws_alice.sent_messages[0] == dumps( + ["EVENT", "contact", bob["contact_list_update"][1]] + ), "Alice: Expected to receive the updated contact list (two items)" + assert ws_alice.sent_messages[1] == dumps( + ["EOSE", "contact"] + ), "Alice: Wrong End Of Streaming Event for contact list" + + async def alice_wires_post02_____bob_is_notified( ws_alice: MockWebSocket, ws_bob: MockWebSocket ): From 298021d25a10352a186d647191efbd7d2047c0c3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 14:03:15 +0200 Subject: [PATCH 016/195] feat: add basic UI --- .../relay-details/relay-details.html | 3 + .../components/relay-details/relay-details.js | 31 +++ static/js/index.js | 172 ++++++++++++ static/js/utils.js | 26 ++ templates/nostrrelay/_api_docs.html | 26 ++ templates/nostrrelay/index.html | 260 ++++++++++++------ 6 files changed, 438 insertions(+), 80 deletions(-) create mode 100644 static/components/relay-details/relay-details.html create mode 100644 static/components/relay-details/relay-details.js create mode 100644 static/js/index.js create mode 100644 static/js/utils.js create mode 100644 templates/nostrrelay/_api_docs.html diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html new file mode 100644 index 0000000..3f33cc1 --- /dev/null +++ b/static/components/relay-details/relay-details.html @@ -0,0 +1,3 @@ +
+ xxx +
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js new file mode 100644 index 0000000..91384eb --- /dev/null +++ b/static/components/relay-details/relay-details.js @@ -0,0 +1,31 @@ +async function relayDetails(path) { + const template = await loadTemplateAsync(path) + Vue.component('relay-details', { + name: 'relay-details', + template, + + props: [], + data: function () { + return { + items: [], + formDialogItem: { + show: false, + data: { + name: '', + description: '' + } + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + }, + + created: async function () { + + } + }) +} diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..3932067 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,172 @@ +const relays = async () => { + Vue.component(VueQrcode.name, VueQrcode) + + await relayDetails('static/components/relay-details/relay-details.html') + + new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + filter: '', + relayLinks: [], + formDialogRelay: { + show: false, + showAdvanced: false, + data: { + name: '', + description: '', + type: '', + amount: '', + wallet: '' + } + }, + relayTypes: [ + { + id: 'rating', + label: 'Rating (rate one item from a list)' + }, + { + id: 'poll', + label: 'Poll (choose one item from a list)' + }, + { + id: 'likes', + label: 'Likes (like or dislike an item)' + } + ], + + relaysTable: { + columns: [ + { + name: '', + align: 'left', + label: '', + field: '' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'description', + align: 'left', + label: 'Description', + field: 'description' + }, + { + name: 'type', + align: 'left', + label: 'Type', + field: 'type' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + methods: { + getDefaultRelayData: function () { + return { + name: '', + description: '', + type: this.relayTypes[0], + amount: '100', + wallet: '' + } + }, + getRelayTypeLabel: function (relayType) { + const type = this.relayTypes.find(s => (s.id = relayType)) + return type ? type.label : '?' + }, + openCreateRelayDialog: function () { + this.formDialogRelay.data = this.getDefaultRelayData() + this.formDialogRelay.show = true + }, + getRelays: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/reviews/api/v1/survey', + this.g.user.wallets[0].inkey + ) + this.relayLinks = data.map(c => + mapRelay( + c, + this.relayLinks.find(old => old.id === c.id) + ) + ) + console.log('### relayLinks', this.relayLinks) + } catch (error) { + console.log('### getRelays', error) + LNbits.utils.notifyApiError(error) + } + }, + + createRelay: async function (data) { + try { + data.type = data.type.id + const resp = await LNbits.api.request( + 'POST', + '/reviews/api/v1/survey', + this.g.user.wallets[0].adminkey, + data + ) + + this.relayLinks.unshift(mapRelay(resp.data)) + this.formDialogRelay.show = false + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + deleteRelay: function (relayId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this survet?') + .onOk(async () => { + try { + const response = await LNbits.api.request( + 'DELETE', + '/reviews/api/v1/survey/' + relayId, + this.g.user.wallets[0].adminkey + ) + + this.relayLinks = _.reject(this.relayLinks, function (obj) { + return obj.id === relayId + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, + + sendFormDataRelay: async function () { + console.log('### sendFormDataRelay') + this.createRelay(this.formDialogRelay.data) + }, + + exportrelayCSV: function () { + LNbits.utils.exportCSV( + this.relaysTable.columns, + this.relayLinks, + 'relays' + ) + } + }, + created: async function () { + await this.getRelays() + } + }) +} + +relays() diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..880a367 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,26 @@ +const mapRelay = (obj, oldObj = {}) => { + const relay = {...oldObj, ...obj} + + relay.expanded = oldObj.expanded || false + + return relay +} + +function loadTemplateAsync(path) { + const result = new Promise(resolve => { + const xhttp = new XMLHttpRequest() + + xhttp.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) resolve(this.responseText) + + if (this.status == 404) resolve(`
Page not found: ${path}
`) + } + } + + xhttp.open('GET', path, true) + xhttp.send() + }) + + return result +} diff --git a/templates/nostrrelay/_api_docs.html b/templates/nostrrelay/_api_docs.html new file mode 100644 index 0000000..334e92f --- /dev/null +++ b/templates/nostrrelay/_api_docs.html @@ -0,0 +1,26 @@ + + +

+ Nostr Relay
+ + Created by, + motorina0 +

+
+
+ Swagger REST API Documentation +
+
diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index c8145d2..fb7ff82 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -1,27 +1,115 @@ {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block page %}
-
+
-
Disable relay
-
Enable relay
+ {% raw %} + New relay +
-
WebSocket Chat
+
+
+
Relays
+
- +
+ + + +
+
+ + + + + Export to CSV + + + + +
+
+ + + {% endraw %} +
@@ -30,79 +118,91 @@
- {{SITE_TITLE}} NostrRelay extension + {{SITE_TITLE}} Nostr Relay Extension
- -

- Thiago's Point of Sale is a secure, mobile-ready, instant and - shareable point of sale terminal (PoS) for merchants. The PoS is - linked to your LNbits wallet but completely air-gapped so users can - ONLY create invoices. To share the NostrRelay hit the hash on the - terminal. -

- Created by - DCs, - Ben Arc. + + + {% include "nostrrelay/_api_docs.html" %}
+ + + + + + + + + + + + + + + +
+
xxx
+
+
+ Create Relay + Cancel +
+
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }} - + + + + {% endblock %} From 24795f519fcc8bb34a3adccecf0e9c882af36739 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 14:23:41 +0200 Subject: [PATCH 017/195] feat: update `Create Relay` dialog --- .../relay-details/relay-details.html | 4 +- .../components/relay-details/relay-details.js | 6 +- static/js/index.js | 5 +- templates/nostrrelay/index.html | 64 ++++++++++--------- 4 files changed, 39 insertions(+), 40 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 3f33cc1..5baf10f 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -1,3 +1 @@ -
- xxx -
+
xxx
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 91384eb..5cda00f 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -21,11 +21,9 @@ async function relayDetails(path) { methods: { satBtc(val, showUnit = true) { return satOrBtc(val, showUnit, this.satsDenominated) - }, + } }, - created: async function () { - - } + created: async function () {} }) } diff --git a/static/js/index.js b/static/js/index.js index 3932067..633b701 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -16,8 +16,9 @@ const relays = async () => { data: { name: '', description: '', - type: '', - amount: '', + pubkey: '', + contact: '', + contact: '', wallet: '' } }, diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index fb7ff82..a06605c 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -98,11 +98,11 @@
- + >
@@ -130,7 +130,18 @@ + +
New Relay
+
+ - - - - - - - + v-model.trim="formDialogRelay.data.contact" + type="text" + label="Contact" + > + -
-
xxx
+
+ +
Create Relay From 0db0d9c351aa7072f6ec88c5377e3b62d0db3039 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 14:23:54 +0200 Subject: [PATCH 018/195] feat: add more fields to `relays` --- migrations.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/migrations.py b/migrations.py index 6f68b22..7d170ea 100644 --- a/migrations.py +++ b/migrations.py @@ -6,8 +6,14 @@ async def m001_initial(db): """ CREATE TABLE nostrrelay.relays ( id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL + name TEXT NOT NULL, + description TEXT, + pubkey TEXT, + contact TEXT, + supported_nips TEXT, + software TEXT, + version TEXT, + meta TEXT NOT NULL DEFAULT '{}' ); """ ) From a849dea99f8431b8aa58269fcf504702994331f5 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 15:30:05 +0200 Subject: [PATCH 019/195] feat: add UI for basic relay operations --- crud.py | 53 +++++++++++++++++++++-- migrations.py | 6 +-- models.py | 18 +++----- static/js/index.js | 52 ++++++++++------------- templates/nostrrelay/index.html | 11 ++--- views_api.py | 74 +++++++++++++++++++++++++++++---- 6 files changed, 152 insertions(+), 62 deletions(-) diff --git a/crud.py b/crud.py index ed5c619..7882943 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,59 @@ import json from typing import Any, List, Optional +from lnbits.helpers import urlsafe_short_hash + from . import db -from .models import NostrEvent, NostrFilter +from .models import NostrEvent, NostrFilter, NostrRelay + +########################## RELAYS #################### + +async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: + await db.execute( + """ + INSERT INTO nostrrelay.relays (user_id, id, name, description, pubkey, contact) + VALUES (?, ?, ?, ?, ?, ?) + """, + (user_id, r.id, r.name, r.description, r.pubkey, r.contact,), + ) + relay = await get_relay(user_id, r.id) + assert relay, "Created relay cannot be retrieved" + return relay +async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: + row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,)) + + return NostrRelay.from_row(row) if row else None + +async def get_relays(user_id: str) -> List[NostrRelay]: + rows = await db.fetchall("""SELECT * FROM nostrrelay.relays WHERE user_id = ?""", (user_id,)) + + return [NostrRelay.from_row(row) for row in rows] + + +async def get_public_relay(relay_id: str) -> Optional[dict]: + row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)) + + if row: + relay = NostrRelay.parse_obj({"id": row["id"], **json.loads(row["meta"])}) + + return { + "id": relay.id, + "name": relay.name, + "description":relay.description, + "pubkey":relay.pubkey, + "contact":relay.contact, + "supported_nips":relay.supported_nips, + } + return None + + +async def delete_relay(user_id: str, relay_id: str): + await db.execute("""DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,)) + + +########################## EVENTS #################### async def create_event(relay_id: str, e: NostrEvent): await db.execute( """ @@ -28,7 +77,6 @@ async def create_event(relay_id: str, e: NostrEvent): extra = json.dumps(rest) if rest else None await create_event_tags(relay_id, e.id, name, value, extra) - async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> List[NostrEvent]: values, query = build_select_events_query(relay_id, filter) @@ -43,7 +91,6 @@ async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> return events - async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: row = await db.fetchone("SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", (relay_id, id,)) if not row: diff --git a/migrations.py b/migrations.py index 7d170ea..bcf9074 100644 --- a/migrations.py +++ b/migrations.py @@ -2,17 +2,17 @@ async def m001_initial(db): """ Initial nostrrelays tables. """ + await db.execute( """ CREATE TABLE nostrrelay.relays ( + user_id TEXT NOT NULL, id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, pubkey TEXT, contact TEXT, - supported_nips TEXT, - software TEXT, - version TEXT, + active BOOLEAN DEFAULT false, meta TEXT NOT NULL DEFAULT '{}' ); """ diff --git a/models.py b/models.py index bb1efe5..5c01495 100644 --- a/models.py +++ b/models.py @@ -10,25 +10,19 @@ from secp256k1 import PublicKey class NostrRelay(BaseModel): id: str - wallet: str name: str - currency: str - tip_options: Optional[str] - tip_wallet: Optional[str] - - @classmethod - def from_row(cls, row: Row) -> "NostrRelay": - return cls(**dict(row)) - - -class NostrRelayInfo(BaseModel): - name: Optional[str] description: Optional[str] pubkey: Optional[str] contact: Optional[str] = "https://t.me/lnbits" supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"] software: Optional[str] = "LNbist" version: Optional[str] + # meta: Optional[str] + + @classmethod + def from_row(cls, row: Row) -> "NostrRelay": + return cls(**dict(row)) + class NostrEventType(str, Enum): diff --git a/static/js/index.js b/static/js/index.js index 633b701..3185beb 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -22,29 +22,22 @@ const relays = async () => { wallet: '' } }, - relayTypes: [ - { - id: 'rating', - label: 'Rating (rate one item from a list)' - }, - { - id: 'poll', - label: 'Poll (choose one item from a list)' - }, - { - id: 'likes', - label: 'Likes (like or dislike an item)' - } - ], relaysTable: { columns: [ + { + name: 'id', + align: 'left', + label: 'ID', + field: 'id' + }, { name: '', align: 'left', label: '', field: '' }, + { name: 'name', align: 'left', @@ -58,16 +51,16 @@ const relays = async () => { field: 'description' }, { - name: 'type', + name: 'pubkey', align: 'left', - label: 'Type', - field: 'type' + label: 'Public Key', + field: 'pubkey' }, { - name: 'amount', + name: 'contact', align: 'left', - label: 'Amount', - field: 'amount' + label: 'Contact', + field: 'contact' } ], pagination: { @@ -79,17 +72,14 @@ const relays = async () => { methods: { getDefaultRelayData: function () { return { + id: '', name: '', description: '', - type: this.relayTypes[0], - amount: '100', - wallet: '' + pubkey: '', + contact: '' } }, - getRelayTypeLabel: function (relayType) { - const type = this.relayTypes.find(s => (s.id = relayType)) - return type ? type.label : '?' - }, + openCreateRelayDialog: function () { this.formDialogRelay.data = this.getDefaultRelayData() this.formDialogRelay.show = true @@ -98,7 +88,7 @@ const relays = async () => { try { const {data} = await LNbits.api.request( 'GET', - '/reviews/api/v1/survey', + '/nostrrelay/api/v1/relay', this.g.user.wallets[0].inkey ) this.relayLinks = data.map(c => @@ -116,10 +106,10 @@ const relays = async () => { createRelay: async function (data) { try { - data.type = data.type.id + console.log('### createRelay', data) const resp = await LNbits.api.request( 'POST', - '/reviews/api/v1/survey', + '/nostrrelay/api/v1/relay', this.g.user.wallets[0].adminkey, data ) @@ -138,7 +128,7 @@ const relays = async () => { try { const response = await LNbits.api.request( 'DELETE', - '/reviews/api/v1/survey/' + relayId, + '/nostrrelay/api/v1/relay/' + relayId, this.g.user.wallets[0].adminkey ) diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index a06605c..9e62c7a 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -69,14 +69,15 @@ {{props.row.name}} - + {{props.row.id}} + {{props.row.description}} - -
{{getRelayTypeLabel(props.row.type)}}
+ +
{{props.row.contact}}
- -
{{props.row.amount}}
+ +
{{props.row.pubkey}}
diff --git a/views_api.py b/views_api.py index 60c268c..0e204ba 100644 --- a/views_api.py +++ b/views_api.py @@ -1,12 +1,23 @@ from http import HTTPStatus +from typing import List, Optional -from fastapi import Query, WebSocket +from fastapi import Depends, Query, WebSocket +from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse from loguru import logger +from lnbits.decorators import ( + WalletTypeInfo, + check_admin, + require_admin_key, + require_invoice_key, +) +from lnbits.helpers import urlsafe_short_hash + from . import nostrrelay_ext from .client_manager import NostrClientConnection, NostrClientManager -from .models import NostrRelayInfo +from .crud import create_relay, delete_relay, get_relay, get_relays +from .models import NostrRelay client_manager = NostrClientManager() @@ -29,14 +40,61 @@ async def api_nostrrelay_info(): "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET" } - info = NostrRelayInfo() + info = NostrRelay() return JSONResponse(content=dict(info), headers=headers) -@nostrrelay_ext.get("/api/v1/enable", status_code=HTTPStatus.OK) -async def api_nostrrelay(enable: bool = Query(True)): - return await enable_relay(enable) + +@nostrrelay_ext.post("/api/v1/relay") +async def api_create_survey(data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay: + + try: + relay = await create_relay(wallet.wallet.user, data) + return relay + + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create relay", + ) -async def enable_relay(enable: bool): - return enable +@nostrrelay_ext.get("/api/v1/relay") +async def api_get_relays(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> List[NostrRelay]: + try: + return await get_relays(wallet.wallet.user) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot fetch relays", + ) + +@nostrrelay_ext.get("/api/v1/relay/{relay_id}") +async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)) -> Optional[NostrRelay]: + try: + relay = await get_relay(wallet.wallet.user, relay_id) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot fetch relay", + ) + if not relay: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Cannot find relay", + ) + return relay + +@nostrrelay_ext.delete("/api/v1/relay/{relay_id}") +async def api_delete_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)): + try: + await delete_relay(wallet.wallet.user, relay_id) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot delete relay", + ) From bc605513139b7f68210a20d33d163416d3a7add5 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 16:33:08 +0200 Subject: [PATCH 020/195] feat: toggle relays `on` and `off` --- crud.py | 13 +++++++- models.py | 1 + static/js/index.js | 53 +++++++++++++++++++++++++-------- templates/nostrrelay/index.html | 33 ++++++++------------ views_api.py | 32 ++++++++++++++++++-- 5 files changed, 95 insertions(+), 37 deletions(-) diff --git a/crud.py b/crud.py index 7882943..cf14bbb 100644 --- a/crud.py +++ b/crud.py @@ -20,6 +20,17 @@ async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: assert relay, "Created relay cannot be retrieved" return relay +async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay: + await db.execute( + """ + UPDATE nostrrelay.relays + SET (name, description, pubkey, contact, active) = (?, ?, ?, ?, ?) + WHERE user_id = ? AND id = ? + """, + (r.name, r.description, r.pubkey, r.contact, r.active, user_id, r.id), + ) + + return r async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,)) @@ -27,7 +38,7 @@ async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: return NostrRelay.from_row(row) if row else None async def get_relays(user_id: str) -> List[NostrRelay]: - rows = await db.fetchall("""SELECT * FROM nostrrelay.relays WHERE user_id = ?""", (user_id,)) + rows = await db.fetchall("""SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""", (user_id,)) return [NostrRelay.from_row(row) for row in rows] diff --git a/models.py b/models.py index 5c01495..f43153e 100644 --- a/models.py +++ b/models.py @@ -14,6 +14,7 @@ class NostrRelay(BaseModel): description: Optional[str] pubkey: Optional[str] contact: Optional[str] = "https://t.me/lnbits" + active: bool = False supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"] software: Optional[str] = "LNbist" version: Optional[str] diff --git a/static/js/index.js b/static/js/index.js index 3185beb..aaffaa1 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -12,32 +12,29 @@ const relays = async () => { relayLinks: [], formDialogRelay: { show: false, - showAdvanced: false, data: { + id: '', name: '', description: '', pubkey: '', - contact: '', - contact: '', - wallet: '' + contact: '' } }, relaysTable: { columns: [ - { - name: 'id', - align: 'left', - label: 'ID', - field: 'id' - }, { name: '', align: 'left', label: '', field: '' }, - + { + name: 'id', + align: 'left', + label: 'ID', + field: 'id' + }, { name: 'name', align: 'left', @@ -106,7 +103,6 @@ const relays = async () => { createRelay: async function (data) { try { - console.log('### createRelay', data) const resp = await LNbits.api.request( 'POST', '/nostrrelay/api/v1/relay', @@ -120,10 +116,41 @@ const relays = async () => { LNbits.utils.notifyApiError(error) } }, + showToggleRelayDialog: function (relay) { + console.log('### showToggleRelayDialog', relay) + if (relay.active) { + this.toggleRelay(relay) + return + } + LNbits.utils + .confirmDialog('Are you sure you want to deactivate this relay?') + .onOk(async () => { + this.toggleRelay(relay) + }) + .onCancel(async () => { + console.log('#### onCancel') + relay.active = !relay.active + }) + }, + toggleRelay: async function (relay) { + console.log('### toggleRelay', relay) + try { + const response = await LNbits.api.request( + 'PUT', + '/nostrrelay/api/v1/relay/' + relay.id, + this.g.user.wallets[0].adminkey, + relay + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, deleteRelay: function (relayId) { LNbits.utils - .confirmDialog('Are you sure you want to delete this survet?') + .confirmDialog( + 'All data will be lost! Are you sure you want to delete this relay?' + ) .onOk(async () => { try { const response = await LNbits.api.request( diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index 9e62c7a..fee5df6 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -68,23 +68,29 @@ />
- {{props.row.name}} {{props.row.id}} + {{props.row.name}} {{props.row.description}} - -
{{props.row.contact}}
-
{{props.row.pubkey}}
+ +
{{props.row.contact}}
+
-
ID:
-
{{props.row.id}}
+
+ +
- -
- - -
NostrRelay: +async def api_create_relay(data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay: try: relay = await create_relay(wallet.wallet.user, data) @@ -59,6 +59,34 @@ async def api_create_survey(data: NostrRelay, wallet: WalletTypeInfo = Depends(r detail="Cannot create relay", ) +@nostrrelay_ext.put("/api/v1/relay/{relay_id}") +async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay: + if relay_id != data.id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Cannot change the relay id", + ) + + try: + relay = await get_relay(wallet.wallet.user, data.id) + if not relay: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Relay not found", + ) + updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)}) + updated_relay = await update_relay(wallet.wallet.user, updated_relay) + 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)) -> List[NostrRelay]: From eedaa52bcf4c5e135bbeeb774a446ae32033b894 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 16:43:59 +0200 Subject: [PATCH 021/195] feat: allow admin users to specify custom relay name --- views_api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/views_api.py b/views_api.py index fa123ac..214edd1 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,6 @@ from http import HTTPStatus from typing import List, Optional - +from pydantic.types import UUID4 from fastapi import Depends, Query, WebSocket from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse @@ -47,7 +47,11 @@ async def api_nostrrelay_info(): @nostrrelay_ext.post("/api/v1/relay") async def api_create_relay(data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay: - + if len(data.id): + await check_admin(UUID4(wallet.wallet.user)) + else: + data.id = urlsafe_short_hash()[:8] + try: relay = await create_relay(wallet.wallet.user, data) return relay From aff949fed5e9cbfe41fadc28de5044906db62c36 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 16:58:29 +0200 Subject: [PATCH 022/195] feat: allow custom relay IDs --- client_manager.py | 15 ++++++++------- crud.py | 22 +++++++++++----------- views_api.py | 31 ++++++++++++++++++++++--------- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/client_manager.py b/client_manager.py index 31325c9..4d85185 100644 --- a/client_manager.py +++ b/client_manager.py @@ -34,8 +34,9 @@ class NostrClientManager: class NostrClientConnection: broadcast_event: Callable - def __init__(self, websocket: WebSocket): + def __init__(self, relay_id: str, websocket: WebSocket): self.websocket = websocket + self.relay_id = relay_id self.filters: List[NostrFilter] = [] async def start(self): @@ -83,9 +84,9 @@ class NostrClientConnection: e.check_signature() if e.is_replaceable_event(): await delete_events( - "111", NostrFilter(kinds=[e.kind], authors=[e.pubkey]) + self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey]) ) - await create_event("111", e) + await create_event(self.relay_id, e) await self.broadcast_event(self, e) if e.is_delete_event(): await self.__handle_delete_event(e) @@ -93,7 +94,7 @@ class NostrClientConnection: except ValueError: resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] except Exception: - event = await get_event("111", e.id) + event = await get_event(self.relay_id, e.id) # todo: handle NIP20 in detail resp_nip20 += [event != None, f"error: failed to create event"] @@ -103,15 +104,15 @@ class NostrClientConnection: # 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("111", filter, False) + 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("111", NostrFilter(ids=ids)) + await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) async def __handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id self.remove_filter(subscription_id) self.filters.append(filter) - events = await get_events("111", filter) + events = await get_events(self.relay_id, filter) serialized_events = [ event.serialize_response(subscription_id) for event in events ] diff --git a/crud.py b/crud.py index cf14bbb..42d4db1 100644 --- a/crud.py +++ b/crud.py @@ -46,18 +46,18 @@ async def get_relays(user_id: str) -> List[NostrRelay]: async def get_public_relay(relay_id: str) -> Optional[dict]: row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)) - if row: - relay = NostrRelay.parse_obj({"id": row["id"], **json.loads(row["meta"])}) + if not row: + return None - return { - "id": relay.id, - "name": relay.name, - "description":relay.description, - "pubkey":relay.pubkey, - "contact":relay.contact, - "supported_nips":relay.supported_nips, - } - return None + relay = NostrRelay.from_row(row) + return { + "id": relay.id, + "name": relay.name, + "description":relay.description, + "pubkey":relay.pubkey, + "contact":relay.contact, + "supported_nips":relay.supported_nips, + } async def delete_relay(user_id: str, relay_id: str): diff --git a/views_api.py b/views_api.py index 214edd1..45fc7a7 100644 --- a/views_api.py +++ b/views_api.py @@ -1,10 +1,11 @@ from http import HTTPStatus from typing import List, Optional -from pydantic.types import UUID4 + from fastapi import Depends, Query, WebSocket from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse from loguru import logger +from pydantic.types import UUID4 from lnbits.decorators import ( WalletTypeInfo, @@ -16,15 +17,22 @@ from lnbits.helpers import urlsafe_short_hash from . import nostrrelay_ext from .client_manager import NostrClientConnection, NostrClientManager -from .crud import create_relay, delete_relay, get_relay, get_relays, update_relay +from .crud import ( + create_relay, + delete_relay, + get_public_relay, + get_relay, + get_relays, + update_relay, +) from .models import NostrRelay client_manager = NostrClientManager() -@nostrrelay_ext.websocket("/client") -async def websocket_endpoint(websocket: WebSocket): - client = NostrClientConnection(websocket=websocket) +@nostrrelay_ext.websocket("/{relay_id}") +async def websocket_endpoint(relay_id: str, websocket: WebSocket): + client = NostrClientConnection(relay_id=relay_id, websocket=websocket) client_manager.add_client(client) try: await client.start() @@ -33,15 +41,20 @@ async def websocket_endpoint(websocket: WebSocket): client_manager.remove_client(client) -@nostrrelay_ext.get("/client", status_code=HTTPStatus.OK) -async def api_nostrrelay_info(): +@nostrrelay_ext.get("/{relay_id}", status_code=HTTPStatus.OK) +async def api_nostrrelay_info(relay_id: str): headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET" } - info = NostrRelay() - return JSONResponse(content=dict(info), headers=headers) + relay = await get_public_relay(relay_id) + if not relay: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Relay not found", + ) + return JSONResponse(content=relay, headers=headers) From dedcf823bd09a4096ad455a066f7e7eabb9d6f2b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 17:03:20 +0200 Subject: [PATCH 023/195] refactor: NIPs endpoint --- views_api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/views_api.py b/views_api.py index 45fc7a7..ea1369c 100644 --- a/views_api.py +++ b/views_api.py @@ -28,7 +28,7 @@ from .crud import ( from .models import NostrRelay client_manager = NostrClientManager() - +active_relays: List[str] = [] @nostrrelay_ext.websocket("/{relay_id}") async def websocket_endpoint(relay_id: str, websocket: WebSocket): @@ -43,18 +43,18 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket): @nostrrelay_ext.get("/{relay_id}", status_code=HTTPStatus.OK) async def api_nostrrelay_info(relay_id: str): - headers = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Allow-Methods": "GET" - } relay = await get_public_relay(relay_id) if not relay: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Relay not found", ) - return JSONResponse(content=relay, headers=headers) + + return JSONResponse(content=relay, headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "GET" + }) From f56e9e2e56566a43a6de920a712244b1bc5969aa Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 17:42:27 +0200 Subject: [PATCH 024/195] feat: block access to deactivated client --- client_manager.py | 30 +++++++++++++++++++++++++++--- crud.py | 3 +++ views_api.py | 9 ++++++--- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/client_manager.py b/client_manager.py index 4d85185..b4d5b73 100644 --- a/client_manager.py +++ b/client_manager.py @@ -1,5 +1,5 @@ import json -from typing import Any, Callable, List +from typing import Any, Callable, List, Optional from fastapi import WebSocket from loguru import logger @@ -7,6 +7,7 @@ from loguru import logger from .crud import ( create_event, delete_events, + get_all_active_relays_ids, get_event, get_events, mark_events_deleted, @@ -15,13 +16,19 @@ from .models import NostrEvent, NostrEventType, NostrFilter class NostrClientManager: - def __init__(self): + def __init__(self: "NostrClientManager"): self.clients: List["NostrClientConnection"] = [] + self.active_relays: Optional[List[str]] = None - def add_client(self, client: "NostrClientConnection"): + async def add_client(self, client: "NostrClientConnection") -> bool: + allow_connect = await self.allow_client_to_connect(client.relay_id, client.websocket) + if not allow_connect: + return False setattr(client, "broadcast_event", self.broadcast_event) self.clients.append(client) + return True + def remove_client(self, client: "NostrClientConnection"): self.clients.remove(client) @@ -30,6 +37,23 @@ class NostrClientManager: if client != source: await client.notify_event(event) + async def allow_client_to_connect(self, relay_id:str, websocket: WebSocket) -> bool: + if not self.active_relays: + self.active_relays = await get_all_active_relays_ids() + + if relay_id not in self.active_relays: + await websocket.close(reason=f"Relay '{relay_id}' is not active") + return False + return True + + async def toggle_relay(self, relay_id: str, active: bool): + if not self.active_relays: + self.active_relays = await get_all_active_relays_ids() + if active: + self.active_relays.append(relay_id) + else: + self.active_relays = [r for r in self.active_relays if r != relay_id] + class NostrClientConnection: broadcast_event: Callable diff --git a/crud.py b/crud.py index 42d4db1..762d910 100644 --- a/crud.py +++ b/crud.py @@ -42,6 +42,9 @@ async def get_relays(user_id: str) -> List[NostrRelay]: return [NostrRelay.from_row(row) for row in rows] +async def get_all_active_relays_ids() -> List[str]: + rows = await db.fetchall("SELECT id FROM nostrrelay.relays WHERE active = true",) + return [r["id"] for r in rows] async def get_public_relay(relay_id: str) -> Optional[dict]: row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)) diff --git a/views_api.py b/views_api.py index ea1369c..93dddca 100644 --- a/views_api.py +++ b/views_api.py @@ -1,7 +1,7 @@ from http import HTTPStatus from typing import List, Optional -from fastapi import Depends, Query, WebSocket +from fastapi import Depends, WebSocket from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse from loguru import logger @@ -28,12 +28,13 @@ from .crud import ( from .models import NostrRelay client_manager = NostrClientManager() -active_relays: List[str] = [] @nostrrelay_ext.websocket("/{relay_id}") async def websocket_endpoint(relay_id: str, websocket: WebSocket): client = NostrClientConnection(relay_id=relay_id, websocket=websocket) - client_manager.add_client(client) + if not (await client_manager.add_client(client)): + return + try: await client.start() except Exception as e: @@ -41,6 +42,7 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket): client_manager.remove_client(client) + @nostrrelay_ext.get("/{relay_id}", status_code=HTTPStatus.OK) async def api_nostrrelay_info(relay_id: str): relay = await get_public_relay(relay_id) @@ -93,6 +95,7 @@ async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeIn ) updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)}) updated_relay = await update_relay(wallet.wallet.user, updated_relay) + await client_manager.toggle_relay(relay_id, updated_relay.active) return updated_relay except HTTPException as ex: From 4db031c10e3839b1d8cc670f7cad49a03ef677bc Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Feb 2023 17:59:36 +0200 Subject: [PATCH 025/195] feat: close connection to client when relay is deactivated --- client_manager.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/client_manager.py b/client_manager.py index b4d5b73..45f9eb6 100644 --- a/client_manager.py +++ b/client_manager.py @@ -53,6 +53,12 @@ class NostrClientManager: self.active_relays.append(relay_id) else: self.active_relays = [r for r in self.active_relays if r != relay_id] + await self.stop_clients_for_relay(relay_id) + + async def stop_clients_for_relay(self, relay_id: str): + for client in self.clients: + if client.relay_id == relay_id: + await client.stop(reason=f"Relay '{relay_id}' has been deactivated.") class NostrClientConnection: @@ -77,6 +83,14 @@ class NostrClientConnection: except Exception as e: logger.warning(e) + async def stop(self, reason: Optional[str]): + try: + message = reason if reason else "Server closed webocket" + await self.websocket.send_text(json.dumps(["NOTICE", message])) + await self.websocket.close() + except: + pass + async def notify_event(self, event: NostrEvent) -> bool: for filter in self.filters: if filter.matches(event): From e4197ce66d07bcaecc1cba82e5b4a8a048c77ca9 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 11:44:26 +0200 Subject: [PATCH 026/195] fix: `created_at` search --- crud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crud.py b/crud.py index 762d910..612bcfd 100644 --- a/crud.py +++ b/crud.py @@ -217,11 +217,11 @@ def build_where_clause(relay_id:str, filter:NostrFilter): values += filter.kinds if filter.since: - where.append("reated_at >= ?") + where.append("created_at >= ?") values += [filter.since] if filter.until: - where.append("created_at <= ?") + where.append("created_at < ?") values += [filter.until] From 4c9d1a43955588d38f5444fa6e35eccbd68d1887 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 12:21:23 +0200 Subject: [PATCH 027/195] feat: init `lnbits_relay_information` --- __init__.py | 8 ++++++++ crud.py | 4 ++-- models.py | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/__init__.py b/__init__.py index 5df0dec..7884f91 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,7 @@ from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer +from lnbits.settings import settings db = Database("ext_nostrrelay") @@ -23,3 +24,10 @@ def nostrrelay_renderer(): from .views import * # noqa from .views_api import * # noqa +from .models import NostrRelay + +settings.lnbits_relay_information = { + "name": "LNbits Nostr Relay", + "description": "Multiple relays are supported", + **NostrRelay.info() +} diff --git a/crud.py b/crud.py index 612bcfd..629bf91 100644 --- a/crud.py +++ b/crud.py @@ -54,12 +54,12 @@ async def get_public_relay(relay_id: str) -> Optional[dict]: relay = NostrRelay.from_row(row) return { + **NostrRelay.info(), "id": relay.id, "name": relay.name, "description":relay.description, "pubkey":relay.pubkey, - "contact":relay.contact, - "supported_nips":relay.supported_nips, + "contact":relay.contact } diff --git a/models.py b/models.py index f43153e..6f703fd 100644 --- a/models.py +++ b/models.py @@ -13,13 +13,21 @@ class NostrRelay(BaseModel): name: str description: Optional[str] pubkey: Optional[str] - contact: Optional[str] = "https://t.me/lnbits" + contact: Optional[str] active: bool = False - supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"] - software: Optional[str] = "LNbist" - version: Optional[str] + # meta: Optional[str] + @classmethod + def info(cls,) -> dict: + return { + "contact": "https://t.me/lnbits", + "supported_nips": [1, 9, 11, 15, 20], + "software": "LNbits", + "version": "", + } + + @classmethod def from_row(cls, row: Row) -> "NostrRelay": return cls(**dict(row)) From f0857a5609cce6e834cbc3a904aafb7271c9ef06 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 14:10:03 +0200 Subject: [PATCH 028/195] chore: code format --- __init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index 7884f91..849456f 100644 --- a/__init__.py +++ b/__init__.py @@ -22,9 +22,9 @@ def nostrrelay_renderer(): return template_renderer(["lnbits/extensions/nostrrelay/templates"]) +from .models import NostrRelay from .views import * # noqa from .views_api import * # noqa -from .models import NostrRelay settings.lnbits_relay_information = { "name": "LNbits Nostr Relay", From 999bb1683f971302f4b4627bee8d346bdc3ea229 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 14:10:20 +0200 Subject: [PATCH 029/195] feat: move toggle relay up --- templates/nostrrelay/index.html | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index fee5df6..63061fa 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -68,7 +68,15 @@ /> - {{props.row.id}} + + + + {{props.row.name}} {{props.row.description}} @@ -83,14 +91,7 @@
-
- -
+
Date: Tue, 7 Feb 2023 14:24:12 +0200 Subject: [PATCH 030/195] feat: move logic to `relay-details` component --- .../relay-details/relay-details.html | 24 ++++++++++++- .../components/relay-details/relay-details.js | 21 +++++++++++- static/js/index.js | 34 +++++++------------ templates/nostrrelay/index.html | 23 +++---------- 4 files changed, 60 insertions(+), 42 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 5baf10f..87b61ee 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -1 +1,23 @@ -
xxx
+
+
+
+ Update Relay +
+
+ Delete Relay +
+
+
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 5cda00f..1ea2b7d 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -4,7 +4,7 @@ async function relayDetails(path) { name: 'relay-details', template, - props: [], + props: ['relay-id', 'adminkey', 'inkey'], data: function () { return { items: [], @@ -21,6 +21,25 @@ async function relayDetails(path) { methods: { satBtc(val, showUnit = true) { return satOrBtc(val, showUnit, this.satsDenominated) + }, + deleteRelay: function () { + LNbits.utils + .confirmDialog( + 'All data will be lost! Are you sure you want to delete this relay?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrrelay/api/v1/relay/' + this.relayId, + this.adminkey + ) + this.$emit('relay-deleted', this.relayId) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) } }, diff --git a/static/js/index.js b/static/js/index.js index aaffaa1..a76d950 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -35,6 +35,12 @@ const relays = async () => { label: 'ID', field: 'id' }, + { + name: 'toggle', + align: 'left', + label: 'Active', + field: '' + }, { name: 'name', align: 'left', @@ -146,33 +152,17 @@ const relays = async () => { } }, - deleteRelay: function (relayId) { - LNbits.utils - .confirmDialog( - 'All data will be lost! Are you sure you want to delete this relay?' - ) - .onOk(async () => { - try { - const response = await LNbits.api.request( - 'DELETE', - '/nostrrelay/api/v1/relay/' + relayId, - this.g.user.wallets[0].adminkey - ) - - this.relayLinks = _.reject(this.relayLinks, function (obj) { - return obj.id === relayId - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }) - }, - sendFormDataRelay: async function () { console.log('### sendFormDataRelay') this.createRelay(this.formDialogRelay.data) }, + handleRelayDeleted: function (relayId) { + this.relayLinks = _.reject(this.relayLinks, function (obj) { + return obj.id === relayId + }) + }, + exportrelayCSV: function () { LNbits.utils.exportCSV( this.relaysTable.columns, diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index 63061fa..cdf4fb0 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -68,8 +68,8 @@ /> - - + {{props.row.id}} + -
-
-
- Delete Relay -
-
-
-
-
+
+
From 3fe4984026b0d75a290d1f746996f51b0757065e Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 14:29:46 +0200 Subject: [PATCH 031/195] feat: add tabs for relay --- .../relay-details/relay-details.html | 61 ++++++++++++------- .../components/relay-details/relay-details.js | 2 +- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 87b61ee..827b545 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -1,23 +1,42 @@
-
-
- Update Relay -
-
- Delete Relay -
-
+ + + + + + + + +
+
+ Update Relay +
+
+ Delete Relay +
+
+
+ + yyyy + + + zzz + + + qqq + +
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 1ea2b7d..19df2b6 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -7,7 +7,7 @@ async function relayDetails(path) { props: ['relay-id', 'adminkey', 'inkey'], data: function () { return { - items: [], + tab: 'info', formDialogItem: { show: false, data: { From cab870f6ffffad1f341e08f81a32a9ad16347345 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 14:31:56 +0200 Subject: [PATCH 032/195] fix: only send required info for toggling a relay --- static/js/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index a76d950..66a9f49 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -141,11 +141,15 @@ const relays = async () => { toggleRelay: async function (relay) { console.log('### toggleRelay', relay) try { - const response = await LNbits.api.request( + await LNbits.api.request( 'PUT', '/nostrrelay/api/v1/relay/' + relay.id, this.g.user.wallets[0].adminkey, - relay + { + active: relay.active, + id: relay.id, + name: relay.name + } ) } catch (error) { LNbits.utils.notifyApiError(error) From c05ecb054dd5cb06245bc01e7c9923f023dfa38d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 14:44:11 +0200 Subject: [PATCH 033/195] feat: edit relay info --- .../relay-details/relay-details.html | 86 +++++++++++++++---- .../components/relay-details/relay-details.js | 17 +++- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 827b545..af14561 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -7,25 +7,54 @@ -
-
- Update Relay +
+
+
Name:
+
+ +
+
-
- Delete Relay +
+
Description:
+
+ +
+
+
+
+
Relay Public Key:
+
+ +
+
+
+
+
Contact:
+
+ +
+
@@ -39,4 +68,25 @@ qqq +
+
+ Update Relay +
+
+ Delete Relay +
+
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 19df2b6..d3c2d72 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -8,6 +8,7 @@ async function relayDetails(path) { data: function () { return { tab: 'info', + relay: null, formDialogItem: { show: false, data: { @@ -40,9 +41,23 @@ async function relayDetails(path) { LNbits.utils.notifyApiError(error) } }) + }, + getRelay: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrrelay/api/v1/relay/' + this.relayId, + this.inkey + ) + this.relay = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } } }, - created: async function () {} + created: async function () { + await this.getRelay() + } }) } From a74cf42feb5f6839488868ef7e4a9470e52271d1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 14:57:32 +0200 Subject: [PATCH 034/195] feat: save relay info updates --- .../components/relay-details/relay-details.js | 24 +++++++++++++++++++ static/js/index.js | 8 ++++++- templates/nostrrelay/index.html | 1 + 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index d3c2d72..a5e53a2 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -36,6 +36,11 @@ async function relayDetails(path) { this.adminkey ) this.$emit('relay-deleted', this.relayId) + this.$q.notify({ + type: 'positive', + message: 'Relay Deleted', + timeout: 5000 + }) } catch (error) { console.warn(error) LNbits.utils.notifyApiError(error) @@ -53,6 +58,25 @@ async function relayDetails(path) { } catch (error) { LNbits.utils.notifyApiError(error) } + }, + updateRelay: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/nostrrelay/api/v1/relay/' + this.relayId, + this.adminkey, + this.relay + ) + this.relay = data + this.$emit('relay-updated', this.relay) + this.$q.notify({ + type: 'positive', + message: 'Relay Updated', + timeout: 5000 + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } } }, diff --git a/static/js/index.js b/static/js/index.js index 66a9f49..950edd1 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -157,7 +157,6 @@ const relays = async () => { }, sendFormDataRelay: async function () { - console.log('### sendFormDataRelay') this.createRelay(this.formDialogRelay.data) }, @@ -166,6 +165,13 @@ const relays = async () => { return obj.id === relayId }) }, + handleRelayUpdated: function (relay) { + const index = this.relayLinks.findIndex(r => r.id === relay.id) + if (index !== -1) { + relay.expanded = true + this.relayLinks.splice(index, 1, relay) + } + }, exportrelayCSV: function () { LNbits.utils.exportCSV( diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index cdf4fb0..dcc51ea 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -97,6 +97,7 @@ :adminkey="g.user.wallets[0].adminkey" :inkey="g.user.wallets[0].inkey" @relay-deleted="handleRelayDeleted" + @relay-updated="handleRelayUpdated" >
From 43f3fbfb444f47ef19ebdd7f64ecba1e26efeb9c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 16:03:13 +0200 Subject: [PATCH 035/195] feat: add payment options --- .../relay-details/relay-details.html | 94 ++++++++++++++++++- .../components/relay-details/relay-details.js | 15 ++- templates/nostrrelay/index.html | 1 + 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index af14561..3f7744a 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -59,7 +59,99 @@
- yyyy +
+
+
Require Payment:
+
+ +
+
+ + +
+
+
+
Cost to join (sats):
+
+ +
+
+ Free to join + +
+
+
+
Cost per Kilo Byte (sats):
+
+ +
+
+ Unlimited storage + +
+
+
+
Free storage (kb):
+
+ +
+
+ No free storage + +
+
+
zzz diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index a5e53a2..9f002c0 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -4,7 +4,7 @@ async function relayDetails(path) { name: 'relay-details', template, - props: ['relay-id', 'adminkey', 'inkey'], + props: ['relay-id', 'adminkey', 'inkey', 'wallet-options'], data: function () { return { tab: 'info', @@ -54,7 +54,16 @@ async function relayDetails(path) { '/nostrrelay/api/v1/relay/' + this.relayId, this.inkey ) + data.config = { + isPaidRelay: true, + wallet: '', + costToJoin: 0, + freeStorage: 0, + storageCostPerKb: 0 + } this.relay = data + + console.log('### this.relay', this.relay) } catch (error) { LNbits.utils.notifyApiError(error) } @@ -77,6 +86,10 @@ async function relayDetails(path) { } catch (error) { LNbits.utils.notifyApiError(error) } + }, + togglePaidRelay: async function () { + this.relay.config.wallet = + this.relay.config.wallet || this.walletOptions[0].value } }, diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html index dcc51ea..c001f66 100644 --- a/templates/nostrrelay/index.html +++ b/templates/nostrrelay/index.html @@ -96,6 +96,7 @@ :relay-id="props.row.id" :adminkey="g.user.wallets[0].adminkey" :inkey="g.user.wallets[0].inkey" + :wallet-options="g.user.walletOptions" @relay-deleted="handleRelayDeleted" @relay-updated="handleRelayUpdated" > From 1e2307a758dea6d23c6ca1251a1584c76415dbd6 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 16:10:52 +0200 Subject: [PATCH 036/195] fix: ui conditional view --- static/components/relay-details/relay-details.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 3f7744a..3730477 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -109,7 +109,7 @@ v-if="relay.config.isPaidRelay" class="row items-center no-wrap q-mb-md" > -
Cost per Kilo Byte (sats):
+
Storage cost per Kilo-Byte (sats):
Free storage (kb):
From 709e201fec3033bb133e624b67cfb26961b826a7 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 16:41:57 +0200 Subject: [PATCH 037/195] feat: add UI for blocked/allowed public keys --- .../relay-details/relay-details.html | 88 ++++++++++++++++++- .../components/relay-details/relay-details.js | 23 ++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 3730477..c108a16 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -3,7 +3,8 @@ - + + @@ -156,8 +157,89 @@ zzz - - qqq + +
+
+
Allowed Public Key
+
+ +
+
+ Add +
+
+
+
+
+ {{p}} + +
+
+
+
+ +
+
+
Blocked Public Key
+
+ +
+
+ Add +
+
+
+
+
+ {{p}} + +
+
+
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 9f002c0..09266ec 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -9,6 +9,8 @@ async function relayDetails(path) { return { tab: 'info', relay: null, + blockedPubkey: '', + allowedPubkey: '', formDialogItem: { show: false, data: { @@ -59,7 +61,9 @@ async function relayDetails(path) { wallet: '', costToJoin: 0, freeStorage: 0, - storageCostPerKb: 0 + storageCostPerKb: 0, + allowedPublicKeys: [], + blockedPublicKeys: [] } this.relay = data @@ -90,6 +94,23 @@ async function relayDetails(path) { togglePaidRelay: async function () { this.relay.config.wallet = this.relay.config.wallet || this.walletOptions[0].value + }, + allowPublicKey: function () { + this.relay.config.allowedPublicKeys.push(this.allowedPubkey) + this.allowedPubkey = '' + }, + 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) + }, + + deleteBlockedPublicKey: function (pubKey) { + this.relay.config.blockedPublicKeys = + this.relay.config.blockedPublicKeys.filter(p => p !== pubKey) } }, From f689e829eb72eba29b0ddf89e92377a4148ee3d8 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 16:51:49 +0200 Subject: [PATCH 038/195] feat: add max filters --- .../relay-details/relay-details.html | 23 ++++++++++++++++++- .../components/relay-details/relay-details.js | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index c108a16..ed42511 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -155,7 +155,28 @@
- zzz +
+
+
Max Filters (per client):
+
+ +
+
+ Unlimited Filters + +
+
+
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 09266ec..8327373 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -62,6 +62,7 @@ async function relayDetails(path) { costToJoin: 0, freeStorage: 0, storageCostPerKb: 0, + maxFilters: 0, allowedPublicKeys: [], blockedPublicKeys: [] } From 25dde9571c1b0e5c5b697de24caa0421b50aed83 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 17:50:48 +0200 Subject: [PATCH 039/195] feat: save relay extra config --- crud.py | 10 ++++---- models.py | 25 ++++++++++++++++--- .../relay-details/relay-details.html | 4 +-- .../components/relay-details/relay-details.js | 10 -------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/crud.py b/crud.py index 629bf91..28c986d 100644 --- a/crud.py +++ b/crud.py @@ -11,10 +11,10 @@ from .models import NostrEvent, NostrFilter, NostrRelay async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.execute( """ - INSERT INTO nostrrelay.relays (user_id, id, name, description, pubkey, contact) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO nostrrelay.relays (user_id, id, name, description, pubkey, contact, meta) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (user_id, r.id, r.name, r.description, r.pubkey, r.contact,), + (user_id, r.id, r.name, r.description, r.pubkey, r.contact, json.dumps(dict(r.config))), ) relay = await get_relay(user_id, r.id) assert relay, "Created relay cannot be retrieved" @@ -24,10 +24,10 @@ async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.execute( """ UPDATE nostrrelay.relays - SET (name, description, pubkey, contact, active) = (?, ?, ?, ?, ?) + SET (name, description, pubkey, contact, active, meta) = (?, ?, ?, ?, ?, ?) WHERE user_id = ? AND id = ? """, - (r.name, r.description, r.pubkey, r.contact, r.active, user_id, r.id), + (r.name, r.description, r.pubkey, r.contact, r.active, json.dumps(dict(r.config)), user_id, r.id), ) return r diff --git a/models.py b/models.py index 6f703fd..bb69edc 100644 --- a/models.py +++ b/models.py @@ -8,6 +8,19 @@ from pydantic import BaseModel, Field from secp256k1 import PublicKey +class RelayConfig(BaseModel): + is_paid_relay = Field(False, alias="isPaidRelay") + wallet = Field("") + cost_to_join = Field(0, alias="costToJoin") + free_storage = Field(0, alias="freeStorage") + storage_cost_per_kb = Field(0, alias="storageCostPerKb") + max_client_filters = Field(0, alias="maxClientFilters") + allowed_public_keys = Field([], alias="allowedPublicKeys") + blocked_public_keys = Field([], alias="blockedPublicKeys") + + class Config: + allow_population_by_field_name = True + class NostrRelay(BaseModel): id: str name: str @@ -16,7 +29,14 @@ class NostrRelay(BaseModel): contact: Optional[str] active: bool = False - # meta: Optional[str] + config: "RelayConfig" = RelayConfig() + + + @classmethod + def from_row(cls, row: Row) -> "NostrRelay": + relay = cls(**dict(row)) + relay.config = RelayConfig(**json.loads(row["meta"])) + return relay @classmethod def info(cls,) -> dict: @@ -28,9 +48,6 @@ class NostrRelay(BaseModel): } - @classmethod - def from_row(cls, row: Row) -> "NostrRelay": - return cls(**dict(row)) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index ed42511..ad36d51 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -162,14 +162,14 @@
Unlimited Filters diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 8327373..2eaf8fe 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -56,16 +56,6 @@ async function relayDetails(path) { '/nostrrelay/api/v1/relay/' + this.relayId, this.inkey ) - data.config = { - isPaidRelay: true, - wallet: '', - costToJoin: 0, - freeStorage: 0, - storageCostPerKb: 0, - maxFilters: 0, - allowedPublicKeys: [], - blockedPublicKeys: [] - } this.relay = data console.log('### this.relay', this.relay) From 14df3a6ffb39e04b2202eb13a0873a8c72d58e3a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Feb 2023 18:15:43 +0200 Subject: [PATCH 040/195] tests: update after changes --- client_manager.py | 2 +- tests/test_clients.py | 24 +++++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/client_manager.py b/client_manager.py index 45f9eb6..6e8f548 100644 --- a/client_manager.py +++ b/client_manager.py @@ -62,12 +62,12 @@ class NostrClientManager: class NostrClientConnection: - broadcast_event: Callable def __init__(self, relay_id: str, websocket: WebSocket): self.websocket = websocket self.relay_id = relay_id self.filters: List[NostrFilter] = [] + self.broadcast_event: Optional[Callable] = None async def start(self): await self.websocket.accept() diff --git a/tests/test_clients.py b/tests/test_clients.py index 49fedf7..54a4da2 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,6 +1,7 @@ import asyncio -from copy import deepcopy from json import dumps, loads +from typing import Optional +from loguru import logger import pytest from fastapi import WebSocket @@ -16,6 +17,8 @@ fixtures = get_fixtures("clients") alice = fixtures["alice"] bob = fixtures["bob"] +RELAY_ID = "relay_01" + class MockWebSocket(WebSocket): def __init__(self): @@ -36,10 +39,15 @@ class MockWebSocket(WebSocket): async def wire_mock_data(self, data: dict): await self.fake_wire.put(dumps(data)) + async def close( + self, code: int = 1000, reason: Optional[str] = None + ) -> None: + logger.info(reason) + @pytest.mark.asyncio async def test_alice_and_bob(): - ws_alice, ws_bob = init_clients() + ws_alice, ws_bob = await init_clients() await alice_wires_meta_and_post01(ws_alice) @@ -62,17 +70,19 @@ async def test_alice_and_bob(): await alice_deletes_post01__bob_is_notified(ws_alice, ws_bob) -def init_clients(): +async def init_clients(): client_manager = NostrClientManager() + client_manager.active_relays = [RELAY_ID] + client_manager.toggle_relay(RELAY_ID, True) ws_alice = MockWebSocket() - client_alice = NostrClientConnection(websocket=ws_alice) - client_manager.add_client(client_alice) + client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice) + await client_manager.add_client(client_alice) asyncio.create_task(client_alice.start()) ws_bob = MockWebSocket() - client_bob = NostrClientConnection(websocket=ws_bob) - client_manager.add_client(client_bob) + client_bob = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_bob) + await client_manager.add_client(client_bob) asyncio.create_task(client_bob.start()) return ws_alice, ws_bob From b2b50587842214325c1f2eb8c084735b745fa6eb Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 08:49:52 +0200 Subject: [PATCH 041/195] refactor: extract `client_manager` --- __init__.py | 3 +++ views_api.py | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index 849456f..ef02bed 100644 --- a/__init__.py +++ b/__init__.py @@ -22,10 +22,13 @@ def nostrrelay_renderer(): return template_renderer(["lnbits/extensions/nostrrelay/templates"]) +from .client_manager import NostrClientManager from .models import NostrRelay from .views import * # noqa from .views_api import * # noqa +client_manager = NostrClientManager() + settings.lnbits_relay_information = { "name": "LNbits Nostr Relay", "description": "Multiple relays are supported", diff --git a/views_api.py b/views_api.py index 93dddca..df2591a 100644 --- a/views_api.py +++ b/views_api.py @@ -15,8 +15,8 @@ from lnbits.decorators import ( ) from lnbits.helpers import urlsafe_short_hash -from . import nostrrelay_ext -from .client_manager import NostrClientConnection, NostrClientManager +from . import client_manager, nostrrelay_ext +from .client_manager import NostrClientConnection from .crud import ( create_relay, delete_relay, @@ -27,7 +27,6 @@ from .crud import ( ) from .models import NostrRelay -client_manager = NostrClientManager() @nostrrelay_ext.websocket("/{relay_id}") async def websocket_endpoint(relay_id: str, websocket: WebSocket): From 4e5c2657c93fd59f8df89ff163278c95bccf530f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 08:50:07 +0200 Subject: [PATCH 042/195] chore: code format --- tests/test_clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_clients.py b/tests/test_clients.py index 54a4da2..205e69d 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,10 +1,10 @@ import asyncio from json import dumps, loads from typing import Optional -from loguru import logger import pytest from fastapi import WebSocket +from loguru import logger from lnbits.extensions.nostrrelay.client_manager import ( NostrClientConnection, From f97cd1dff68615df453ca52f2164b7646fc56413 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 08:59:19 +0200 Subject: [PATCH 043/195] fix: group clients by relay --- client_manager.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/client_manager.py b/client_manager.py index 6e8f548..634e3c1 100644 --- a/client_manager.py +++ b/client_manager.py @@ -17,7 +17,7 @@ from .models import NostrEvent, NostrEventType, NostrFilter class NostrClientManager: def __init__(self: "NostrClientManager"): - self.clients: List["NostrClientConnection"] = [] + self.clients: dict = {} self.active_relays: Optional[List[str]] = None async def add_client(self, client: "NostrClientConnection") -> bool: @@ -25,15 +25,15 @@ class NostrClientManager: if not allow_connect: return False setattr(client, "broadcast_event", self.broadcast_event) - self.clients.append(client) + self.relay_clients(client.relay_id).append(client) return True def remove_client(self, client: "NostrClientConnection"): - self.clients.remove(client) + self.relay_clients(client.relay_id).remove(client) async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): - for client in self.clients: + for client in self.relay_clients(source.relay_id): if client != source: await client.notify_event(event) @@ -56,11 +56,15 @@ class NostrClientManager: await self.stop_clients_for_relay(relay_id) async def stop_clients_for_relay(self, relay_id: str): - for client in self.clients: + for client in self.relay_clients(relay_id): if client.relay_id == relay_id: await client.stop(reason=f"Relay '{relay_id}' has been deactivated.") - + def relay_clients(self, relay_id: str) -> List["NostrClientConnection"]: + if relay_id not in self.clients: + self.clients[relay_id] = [] + return self.clients[relay_id] + class NostrClientConnection: def __init__(self, relay_id: str, websocket: WebSocket): From d0341911b98f643c072991c75f173d988a2e299e Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 09:10:04 +0200 Subject: [PATCH 044/195] refactor: extract `NostrFilter.to_sql_components` --- client_manager.py | 6 ++++-- crud.py | 49 +++-------------------------------------------- models.py | 48 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 52 deletions(-) diff --git a/client_manager.py b/client_manager.py index 634e3c1..211318c 100644 --- a/client_manager.py +++ b/client_manager.py @@ -129,13 +129,15 @@ class NostrClientConnection: self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey]) ) await create_event(self.relay_id, e) - await self.broadcast_event(self, e) + if self.broadcast_event: + await self.broadcast_event(self, e) if e.is_delete_event(): await self.__handle_delete_event(e) resp_nip20 += [True, ""] except ValueError: resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] - except Exception: + except Exception as ex: + logger.debug(ex) event = await get_event(self.relay_id, e.id) # todo: handle NIP20 in detail resp_nip20 += [event != None, f"error: failed to create event"] diff --git a/crud.py b/crud.py index 28c986d..da78eab 100644 --- a/crud.py +++ b/crud.py @@ -117,14 +117,14 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: async def mark_events_deleted(relay_id: str, filter: NostrFilter): if filter.is_empty(): return None - _, where, values = build_where_clause(relay_id, filter) + _, where, values = filter.to_sql_components(relay_id) await db.execute(f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", tuple(values)) async def delete_events(relay_id: str, filter: NostrFilter): if filter.is_empty(): return None - _, where, values = build_where_clause(relay_id, filter) + _, where, values = filter.to_sql_components(relay_id) query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}""" await db.execute(query, tuple(values)) @@ -168,7 +168,7 @@ async def get_event_tags( def build_select_events_query(relay_id:str, filter:NostrFilter): - inner_joins, where, values = build_where_clause(relay_id, filter) + inner_joins, where, values = filter.to_sql_components(relay_id) query = f""" SELECT id, pubkey, created_at, kind, content, sig @@ -183,46 +183,3 @@ def build_select_events_query(relay_id:str, filter:NostrFilter): query += f" LIMIT {filter.limit}" return values, query - -def build_where_clause(relay_id:str, filter:NostrFilter): - inner_joins = [] - where = ["deleted=false", "nostrrelay.events.relay_id = ?"] - values: List[Any] = [relay_id] - - if len(filter.e): - values += filter.e - e_s = ",".join(["?"] * len(filter.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(filter.p): - values += filter.p - p_s = ",".join(["?"] * len(filter.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(filter.ids) != 0: - ids = ",".join(["?"] * len(filter.ids)) - where.append(f"id IN ({ids})") - values += filter.ids - - if len(filter.authors) != 0: - authors = ",".join(["?"] * len(filter.authors)) - where.append(f"pubkey IN ({authors})") - values += filter.authors - - if len(filter.kinds) != 0: - kinds = ",".join(["?"] * len(filter.kinds)) - where.append(f"kind IN ({kinds})") - values += filter.kinds - - if filter.since: - where.append("created_at >= ?") - values += [filter.since] - - if filter.until: - where.append("created_at < ?") - values += [filter.until] - - - return inner_joins, where, values \ No newline at end of file diff --git a/models.py b/models.py index bb69edc..ad13718 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ import hashlib import json from enum import Enum from sqlite3 import Row -from typing import List, Optional +from typing import Any, List, Optional, Tuple from pydantic import BaseModel, Field from secp256k1 import PublicKey @@ -48,9 +48,6 @@ class NostrRelay(BaseModel): } - - - class NostrEventType(str, Enum): EVENT = "EVENT" REQ = "REQ" @@ -169,3 +166,46 @@ class NostrFilter(BaseModel): and (not self.since) and (not self.until) ) + + 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 From 6527d049774a1e2db356381965044847eef1f1f2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 09:15:26 +0200 Subject: [PATCH 045/195] refactor: function renaming --- client_manager.py | 54 +++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/client_manager.py b/client_manager.py index 211318c..114b774 100644 --- a/client_manager.py +++ b/client_manager.py @@ -21,31 +21,22 @@ class NostrClientManager: self.active_relays: Optional[List[str]] = None async def add_client(self, client: "NostrClientConnection") -> bool: - allow_connect = await self.allow_client_to_connect(client.relay_id, client.websocket) + allow_connect = await self._allow_client_to_connect(client.relay_id, client.websocket) if not allow_connect: return False setattr(client, "broadcast_event", self.broadcast_event) - self.relay_clients(client.relay_id).append(client) + self._clients(client.relay_id).append(client) return True def remove_client(self, client: "NostrClientConnection"): - self.relay_clients(client.relay_id).remove(client) + self._clients(client.relay_id).remove(client) async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): - for client in self.relay_clients(source.relay_id): + for client in self._clients(source.relay_id): if client != source: await client.notify_event(event) - async def allow_client_to_connect(self, relay_id:str, websocket: WebSocket) -> bool: - if not self.active_relays: - self.active_relays = await get_all_active_relays_ids() - - if relay_id not in self.active_relays: - await websocket.close(reason=f"Relay '{relay_id}' is not active") - return False - return True - async def toggle_relay(self, relay_id: str, active: bool): if not self.active_relays: self.active_relays = await get_all_active_relays_ids() @@ -53,17 +44,26 @@ class NostrClientManager: self.active_relays.append(relay_id) else: self.active_relays = [r for r in self.active_relays if r != relay_id] - await self.stop_clients_for_relay(relay_id) + await self._stop_clients_for_relay(relay_id) - async def stop_clients_for_relay(self, relay_id: str): - for client in self.relay_clients(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: await client.stop(reason=f"Relay '{relay_id}' has been deactivated.") - def relay_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] + + async def _allow_client_to_connect(self, relay_id:str, websocket: WebSocket) -> bool: + if not self.active_relays: + self.active_relays = await get_all_active_relays_ids() + + if relay_id not in self.active_relays: + await websocket.close(reason=f"Relay '{relay_id}' is not active") + return False + return True class NostrClientConnection: @@ -81,7 +81,7 @@ class NostrClientConnection: try: data = json.loads(json_data) - resp = await self.__handle_message(data) + resp = await self._handle_message(data) for r in resp: await self.websocket.send_text(json.dumps(r)) except Exception as e: @@ -103,24 +103,24 @@ class NostrClientConnection: return True return False - async def __handle_message(self, data: List) -> List: + 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])) + 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])) + return await self._handle_request(data[1], NostrFilter.parse_obj(data[2])) if message_type == NostrEventType.CLOSE: - self.__handle_close(data[1]) + self._handle_close(data[1]) return [] - async def __handle_event(self, e: NostrEvent): + async def _handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["OK", e.id] try: e.check_signature() @@ -132,7 +132,7 @@ class NostrClientConnection: if self.broadcast_event: await self.broadcast_event(self, e) if e.is_delete_event(): - await self.__handle_delete_event(e) + await self._handle_delete_event(e) resp_nip20 += [True, ""] except ValueError: resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] @@ -144,7 +144,7 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(resp_nip20)) - async def __handle_delete_event(self, event: NostrEvent): + 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"] @@ -152,7 +152,7 @@ class NostrClientConnection: 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: + async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id self.remove_filter(subscription_id) self.filters.append(filter) @@ -164,7 +164,7 @@ class NostrClientConnection: serialized_events.append(resp_nip15) return serialized_events - def __handle_close(self, subscription_id: str): + def _handle_close(self, subscription_id: str): self.remove_filter(subscription_id) def remove_filter(self, subscription_id: str): From b098dc5d77acb2fb3f380f6a96f9d96dcdd01aa1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 09:29:14 +0200 Subject: [PATCH 046/195] feat: clean-up when relay deleted --- crud.py | 7 ++++++- views_api.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crud.py b/crud.py index da78eab..8e81cde 100644 --- a/crud.py +++ b/crud.py @@ -128,8 +128,14 @@ async def delete_events(relay_id: str, filter: NostrFilter): query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}""" await db.execute(query, tuple(values)) + #todo: delete tags +async def delete_all_events(relay_id: str): + query = "DELETE from nostrrelay.events WHERE relay_id = ?" + await db.execute(query, (relay_id,)) + # todo: delete tags + async def create_event_tags( relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str] ): @@ -147,7 +153,6 @@ async def create_event_tags( (relay_id, event_id, tag_name, tag_value, extra_values), ) - async def get_event_tags( relay_id: str, event_id: str ) -> List[List[str]]: diff --git a/views_api.py b/views_api.py index df2591a..8c9c97f 100644 --- a/views_api.py +++ b/views_api.py @@ -19,6 +19,7 @@ from . import client_manager, nostrrelay_ext from .client_manager import NostrClientConnection from .crud import ( create_relay, + delete_all_events, delete_relay, get_public_relay, get_relay, @@ -138,7 +139,9 @@ async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_ @nostrrelay_ext.delete("/api/v1/relay/{relay_id}") async def api_delete_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)): try: + await client_manager.toggle_relay(relay_id, False) await delete_relay(wallet.wallet.user, relay_id) + await delete_all_events(relay_id) except Exception as ex: logger.warning(ex) raise HTTPException( From 534cf04210abe9fcd1dc004bda1dff7e9d3c711b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 10:21:26 +0200 Subject: [PATCH 047/195] feat: use RelayConfig for active relays --- client_manager.py | 37 +++++++++++++++++++++---------------- crud.py | 12 ++++++++---- tests/test_clients.py | 4 ++-- views_api.py | 9 +++++++-- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/client_manager.py b/client_manager.py index 114b774..51791bf 100644 --- a/client_manager.py +++ b/client_manager.py @@ -7,20 +7,24 @@ from loguru import logger from .crud import ( create_event, delete_events, - get_all_active_relays_ids, get_event, get_events, + get_config_for_all_active_relays, mark_events_deleted, ) -from .models import NostrEvent, NostrEventType, NostrFilter +from .models import NostrEvent, NostrEventType, NostrFilter, RelayConfig class NostrClientManager: def __init__(self: "NostrClientManager"): self.clients: dict = {} - self.active_relays: Optional[List[str]] = None + self.active_relays: dict = {} + self.is_ready = False async def add_client(self, client: "NostrClientConnection") -> bool: + if not self.is_ready: + return False + allow_connect = await self._allow_client_to_connect(client.relay_id, client.websocket) if not allow_connect: return False @@ -29,22 +33,26 @@ class NostrClientManager: return True - def remove_client(self, client: "NostrClientConnection"): - self._clients(client.relay_id).remove(client) + def remove_client(self, c: "NostrClientConnection"): + self._clients(c.relay_id).remove(c) async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): for client in self._clients(source.relay_id): if client != source: await client.notify_event(event) - async def toggle_relay(self, relay_id: str, active: bool): - if not self.active_relays: - self.active_relays = await get_all_active_relays_ids() - if active: - self.active_relays.append(relay_id) - else: - self.active_relays = [r for r in self.active_relays if r != relay_id] - await self._stop_clients_for_relay(relay_id) + async def init_relays(self): + self.active_relays = await get_config_for_all_active_relays() + self.is_ready = True + + async def enable_relay(self, relay_id: str, config: RelayConfig): + self.is_ready = True + self.active_relays[relay_id] = config + + async def disable_relay(self, relay_id: str): + await self._stop_clients_for_relay(relay_id) + del self.active_relays[relay_id] + async def _stop_clients_for_relay(self, relay_id: str): for client in self._clients(relay_id): @@ -57,9 +65,6 @@ class NostrClientManager: return self.clients[relay_id] async def _allow_client_to_connect(self, relay_id:str, websocket: WebSocket) -> bool: - if not self.active_relays: - self.active_relays = await get_all_active_relays_ids() - if relay_id not in self.active_relays: await websocket.close(reason=f"Relay '{relay_id}' is not active") return False diff --git a/crud.py b/crud.py index 8e81cde..9d36059 100644 --- a/crud.py +++ b/crud.py @@ -4,7 +4,7 @@ from typing import Any, List, Optional from lnbits.helpers import urlsafe_short_hash from . import db -from .models import NostrEvent, NostrFilter, NostrRelay +from .models import NostrEvent, NostrFilter, NostrRelay, RelayConfig ########################## RELAYS #################### @@ -42,9 +42,13 @@ async def get_relays(user_id: str) -> List[NostrRelay]: return [NostrRelay.from_row(row) for row in rows] -async def get_all_active_relays_ids() -> List[str]: - rows = await db.fetchall("SELECT id FROM nostrrelay.relays WHERE active = true",) - return [r["id"] for r in rows] +async def get_config_for_all_active_relays() -> dict: + rows = await db.fetchall("SELECT id, meta FROM nostrrelay.relays WHERE active = true",) + active_relay_configs = {} + for r in rows: + active_relay_configs[r["id"]] = RelayConfig(**json.loads(r["meta"])) #todo: from_json + + return active_relay_configs async def get_public_relay(relay_id: str) -> Optional[dict]: row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)) diff --git a/tests/test_clients.py b/tests/test_clients.py index 205e69d..73469eb 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -10,6 +10,7 @@ from lnbits.extensions.nostrrelay.client_manager import ( NostrClientConnection, NostrClientManager, ) +from lnbits.extensions.nostrrelay.models import RelayConfig from .helpers import get_fixtures @@ -72,8 +73,7 @@ async def test_alice_and_bob(): async def init_clients(): client_manager = NostrClientManager() - client_manager.active_relays = [RELAY_ID] - client_manager.toggle_relay(RELAY_ID, True) + await client_manager.enable_relay(RELAY_ID, RelayConfig()) ws_alice = MockWebSocket() client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice) diff --git a/views_api.py b/views_api.py index 8c9c97f..f45c11a 100644 --- a/views_api.py +++ b/views_api.py @@ -95,7 +95,12 @@ async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeIn ) updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)}) updated_relay = await update_relay(wallet.wallet.user, updated_relay) - await client_manager.toggle_relay(relay_id, updated_relay.active) + + if updated_relay.active: + await client_manager.enable_relay(relay_id, updated_relay.config) + else: + await client_manager.disable_relay(relay_id) + return updated_relay except HTTPException as ex: @@ -139,7 +144,7 @@ async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_ @nostrrelay_ext.delete("/api/v1/relay/{relay_id}") async def api_delete_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)): try: - await client_manager.toggle_relay(relay_id, False) + await client_manager.disable_relay(relay_id) await delete_relay(wallet.wallet.user, relay_id) await delete_all_events(relay_id) except Exception as ex: From 9c5b7f69dd193991ae65448f84f9c7ca81745c38 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 10:23:02 +0200 Subject: [PATCH 048/195] fix: init relays --- client_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client_manager.py b/client_manager.py index 51791bf..d6ed1f9 100644 --- a/client_manager.py +++ b/client_manager.py @@ -23,7 +23,7 @@ class NostrClientManager: async def add_client(self, client: "NostrClientConnection") -> bool: if not self.is_ready: - return False + await self.init_relays() allow_connect = await self._allow_client_to_connect(client.relay_id, client.websocket) if not allow_connect: From 30dfb03d2d884a0a261fe908c40bb6437d51ee6f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 10:26:32 +0200 Subject: [PATCH 049/195] refactor: rename private fields --- client_manager.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/client_manager.py b/client_manager.py index d6ed1f9..fd51c6f 100644 --- a/client_manager.py +++ b/client_manager.py @@ -7,9 +7,9 @@ from loguru import logger from .crud import ( create_event, delete_events, + get_config_for_all_active_relays, get_event, get_events, - get_config_for_all_active_relays, mark_events_deleted, ) from .models import NostrEvent, NostrEventType, NostrFilter, RelayConfig @@ -17,55 +17,55 @@ from .models import NostrEvent, NostrEventType, NostrFilter, RelayConfig class NostrClientManager: def __init__(self: "NostrClientManager"): - self.clients: dict = {} - self.active_relays: dict = {} - self.is_ready = False + self._clients: dict = {} + self._active_relays: dict = {} + self._is_ready = False async def add_client(self, client: "NostrClientConnection") -> bool: - if not self.is_ready: + if not self._is_ready: await self.init_relays() - allow_connect = await self._allow_client_to_connect(client.relay_id, client.websocket) + allow_connect = await self._allow_client(client.relay_id, client.websocket) if not allow_connect: return False setattr(client, "broadcast_event", self.broadcast_event) - self._clients(client.relay_id).append(client) + self.clients(client.relay_id).append(client) return True def remove_client(self, c: "NostrClientConnection"): - self._clients(c.relay_id).remove(c) + self.clients(c.relay_id).remove(c) async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): - for client in self._clients(source.relay_id): + for client in self.clients(source.relay_id): if client != source: await client.notify_event(event) async def init_relays(self): - self.active_relays = await get_config_for_all_active_relays() - self.is_ready = True + self._active_relays = await get_config_for_all_active_relays() + self._is_ready = True async def enable_relay(self, relay_id: str, config: RelayConfig): - self.is_ready = True - self.active_relays[relay_id] = config + self._is_ready = True + self._active_relays[relay_id] = config async def disable_relay(self, relay_id: str): await self._stop_clients_for_relay(relay_id) - del self.active_relays[relay_id] + del self._active_relays[relay_id] async def _stop_clients_for_relay(self, relay_id: str): - for client in self._clients(relay_id): + for client in self.clients(relay_id): if client.relay_id == relay_id: await client.stop(reason=f"Relay '{relay_id}' has been deactivated.") - def _clients(self, relay_id: str) -> List["NostrClientConnection"]: - if relay_id not in self.clients: - self.clients[relay_id] = [] - return self.clients[relay_id] + def clients(self, relay_id: str) -> List["NostrClientConnection"]: + if relay_id not in self._clients: + self._clients[relay_id] = [] + return self._clients[relay_id] - async def _allow_client_to_connect(self, relay_id:str, websocket: WebSocket) -> bool: - if relay_id not in self.active_relays: + async def _allow_client(self, relay_id:str, websocket: WebSocket) -> bool: + if relay_id not in self._active_relays: await websocket.close(reason=f"Relay '{relay_id}' is not active") return False return True From e1fe19b115c2dff392c18ff291ffe3bf5d74a069 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 10:47:43 +0200 Subject: [PATCH 050/195] refactoring: small --- client_manager.py | 17 +++++++++++------ models.py | 15 +++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/client_manager.py b/client_manager.py index fd51c6f..07c3cd5 100644 --- a/client_manager.py +++ b/client_manager.py @@ -25,7 +25,7 @@ class NostrClientManager: if not self._is_ready: await self.init_relays() - allow_connect = await self._allow_client(client.relay_id, client.websocket) + allow_connect = await self._allow_client(client) if not allow_connect: return False setattr(client, "broadcast_event", self.broadcast_event) @@ -64,10 +64,11 @@ class NostrClientManager: self._clients[relay_id] = [] return self._clients[relay_id] - async def _allow_client(self, relay_id:str, websocket: WebSocket) -> bool: - if relay_id not in self._active_relays: - await websocket.close(reason=f"Relay '{relay_id}' is not active") + 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 + #todo: NIP-42: AUTH return True class NostrClientConnection: @@ -93,10 +94,14 @@ class NostrClientConnection: logger.warning(e) async def stop(self, reason: Optional[str]): + message = reason if reason else "Server closed webocket" try: - message = reason if reason else "Server closed webocket" await self.websocket.send_text(json.dumps(["NOTICE", message])) - await self.websocket.close() + except: + pass + + try: + await self.websocket.close(reason=reason) except: pass diff --git a/models.py b/models.py index ad13718..655538f 100644 --- a/models.py +++ b/models.py @@ -8,18 +8,21 @@ from pydantic import BaseModel, Field from secp256k1 import PublicKey -class RelayConfig(BaseModel): - is_paid_relay = Field(False, alias="isPaidRelay") - wallet = Field("") - cost_to_join = Field(0, alias="costToJoin") - free_storage = Field(0, alias="freeStorage") - storage_cost_per_kb = Field(0, alias="storageCostPerKb") + +class ClientConfig(BaseModel): max_client_filters = Field(0, alias="maxClientFilters") allowed_public_keys = Field([], alias="allowedPublicKeys") blocked_public_keys = Field([], alias="blockedPublicKeys") class Config: allow_population_by_field_name = True +class RelayConfig(ClientConfig): + is_paid_relay = Field(False, alias="isPaidRelay") + wallet = Field("") + cost_to_join = Field(0, alias="costToJoin") + free_storage = Field(0, alias="freeStorage") + storage_cost_per_kb = Field(0, alias="storageCostPerKb") + class NostrRelay(BaseModel): id: str From 43dc3e85ce86ed343f2095718f97a2309bb003f6 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 11:16:34 +0200 Subject: [PATCH 051/195] feat: check access control list --- client_manager.py | 38 +++++++++++++++++++++++++++++--------- models.py | 9 ++++++++- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/client_manager.py b/client_manager.py index 07c3cd5..8bd0c11 100644 --- a/client_manager.py +++ b/client_manager.py @@ -1,5 +1,5 @@ import json -from typing import Any, Callable, List, Optional +from typing import Any, Awaitable, Callable, List, Optional from fastapi import WebSocket from loguru import logger @@ -12,7 +12,7 @@ from .crud import ( get_events, mark_events_deleted, ) -from .models import NostrEvent, NostrEventType, NostrFilter, RelayConfig +from .models import ClientConfig, NostrEvent, NostrEventType, NostrFilter, RelayConfig class NostrClientManager: @@ -28,7 +28,12 @@ class NostrClientManager: allow_connect = await self._allow_client(client) if not allow_connect: return False + setattr(client, "broadcast_event", self.broadcast_event) + def get_client_config() -> ClientConfig: + return self.get_relay_config(client.relay_id) + setattr(client, "get_client_config", get_client_config) + self.clients(client.relay_id).append(client) return True @@ -52,18 +57,20 @@ class NostrClientManager: async def disable_relay(self, relay_id: str): await self._stop_clients_for_relay(relay_id) del self._active_relays[relay_id] + + def get_relay_config(self, relay_id: str) -> RelayConfig: + return self._active_relays[relay_id] + def clients(self, relay_id: str) -> List["NostrClientConnection"]: + if relay_id not in self._clients: + self._clients[relay_id] = [] + return self._clients[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: await client.stop(reason=f"Relay '{relay_id}' has been deactivated.") - def clients(self, relay_id: str) -> List["NostrClientConnection"]: - if relay_id not in self._clients: - self._clients[relay_id] = [] - return self._clients[relay_id] - 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") @@ -77,7 +84,8 @@ class NostrClientConnection: self.websocket = websocket self.relay_id = relay_id self.filters: List[NostrFilter] = [] - self.broadcast_event: Optional[Callable] = None + self.broadcast_event: Optional[Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]] = None + self.get_client_config: Optional[Callable[[], ClientConfig]] = None async def start(self): await self.websocket.accept() @@ -134,6 +142,10 @@ class NostrClientConnection: resp_nip20: List[Any] = ["OK", e.id] try: e.check_signature() + + if not self.client_config.is_author_allowed(e.pubkey): + raise ValueError(f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!") + if e.is_replaceable_event(): await delete_events( self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey]) @@ -144,7 +156,9 @@ class NostrClientConnection: if e.is_delete_event(): await self._handle_delete_event(e) resp_nip20 += [True, ""] - except ValueError: + except ValueError as ex: + #todo: handle the other Value Errors + logger.debug(ex) resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] except Exception as ex: logger.debug(ex) @@ -154,6 +168,12 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(resp_nip20)) + @property + def client_config(self) -> ClientConfig: + if not self.get_client_config: + raise Exception("Client not ready!") + return self.get_client_config() + async def _handle_delete_event(self, event: NostrEvent): # NIP 09 filter = NostrFilter(authors=[event.pubkey]) diff --git a/models.py b/models.py index 655538f..57e639a 100644 --- a/models.py +++ b/models.py @@ -8,12 +8,19 @@ from pydantic import BaseModel, Field from secp256k1 import PublicKey - class ClientConfig(BaseModel): max_client_filters = Field(0, alias="maxClientFilters") 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 Config: allow_population_by_field_name = True class RelayConfig(ClientConfig): From 5339dde64a6a89a2727247d99bdf087dbb6687e2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 11:40:42 +0200 Subject: [PATCH 052/195] fix: revert `NostrClientManager`init place --- __init__.py | 3 +-- views_api.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index ef02bed..18687e4 100644 --- a/__init__.py +++ b/__init__.py @@ -22,12 +22,11 @@ def nostrrelay_renderer(): return template_renderer(["lnbits/extensions/nostrrelay/templates"]) -from .client_manager import NostrClientManager from .models import NostrRelay from .views import * # noqa from .views_api import * # noqa -client_manager = NostrClientManager() + settings.lnbits_relay_information = { "name": "LNbits Nostr Relay", diff --git a/views_api.py b/views_api.py index f45c11a..5ffb88c 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 client_manager, nostrrelay_ext -from .client_manager import NostrClientConnection +from .client_manager import NostrClientConnection, NostrClientManager from .crud import ( create_relay, delete_all_events, @@ -28,11 +28,13 @@ from .crud import ( ) from .models import NostrRelay +client_manager = NostrClientManager() @nostrrelay_ext.websocket("/{relay_id}") async def websocket_endpoint(relay_id: str, websocket: WebSocket): client = NostrClientConnection(relay_id=relay_id, websocket=websocket) - if not (await client_manager.add_client(client)): + client_accepted = await client_manager.add_client(client) + if not client_accepted: return try: From 7215d37fe9325b61e278e43cfd8059292c4e1daa Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 11:52:07 +0200 Subject: [PATCH 053/195] feat: more detailed error messages for rejected event --- __init__.py | 2 -- client_manager.py | 29 +++++++++++++++++++---------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/__init__.py b/__init__.py index 18687e4..849456f 100644 --- a/__init__.py +++ b/__init__.py @@ -26,8 +26,6 @@ from .models import NostrRelay from .views import * # noqa from .views_api import * # noqa - - settings.lnbits_relay_information = { "name": "LNbits Nostr Relay", "description": "Multiple relays are supported", diff --git a/client_manager.py b/client_manager.py index 8bd0c11..1d7c3d2 100644 --- a/client_manager.py +++ b/client_manager.py @@ -121,6 +121,10 @@ class NostrClientConnection: return True return False + 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 [] @@ -140,31 +144,36 @@ class NostrClientConnection: async def _handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["OK", e.id] + + if not self.client_config.is_author_allowed(e.pubkey): + resp_nip20 += [False, f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!"] + await self.websocket.send_text(json.dumps(resp_nip20)) + return None + try: e.check_signature() + except ValueError as ex: + resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] + await self.websocket.send_text(json.dumps(resp_nip20)) + return None - if not self.client_config.is_author_allowed(e.pubkey): - raise ValueError(f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!") - + try: 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) - if self.broadcast_event: - await self.broadcast_event(self, e) + await self._broadcast_event(e) + if e.is_delete_event(): await self._handle_delete_event(e) resp_nip20 += [True, ""] - except ValueError as ex: - #todo: handle the other Value Errors - logger.debug(ex) - resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] except Exception as ex: logger.debug(ex) event = await get_event(self.relay_id, e.id) # todo: handle NIP20 in detail - resp_nip20 += [event != None, f"error: failed to create event"] + message = "error: failed to create event" + resp_nip20 += [event != None, message] await self.websocket.send_text(json.dumps(resp_nip20)) From 423364a5c6aa67849394ee3ce14f1834a0713c71 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 11:56:03 +0200 Subject: [PATCH 054/195] refactor: extract method `_send_msg()` --- client_manager.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client_manager.py b/client_manager.py index 1d7c3d2..4b77da7 100644 --- a/client_manager.py +++ b/client_manager.py @@ -97,14 +97,14 @@ class NostrClientConnection: resp = await self._handle_message(data) for r in resp: - await self.websocket.send_text(json.dumps(r)) + 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.websocket.send_text(json.dumps(["NOTICE", message])) + await self._send_msg(["NOTICE", message]) except: pass @@ -117,7 +117,7 @@ class NostrClientConnection: for filter in self.filters: if filter.matches(event): resp = event.serialize_response(filter.subscription_id) - await self.websocket.send_text(json.dumps(resp)) + await self._send_msg(resp) return True return False @@ -147,14 +147,14 @@ class NostrClientConnection: if not self.client_config.is_author_allowed(e.pubkey): resp_nip20 += [False, f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!"] - await self.websocket.send_text(json.dumps(resp_nip20)) + await self._send_msg(resp_nip20) return None try: e.check_signature() except ValueError as ex: resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] - await self.websocket.send_text(json.dumps(resp_nip20)) + await self._send_msg(resp_nip20) return None try: @@ -175,7 +175,7 @@ class NostrClientConnection: message = "error: failed to create event" resp_nip20 += [event != None, message] - await self.websocket.send_text(json.dumps(resp_nip20)) + await self._send_msg(resp_nip20) @property def client_config(self) -> ClientConfig: @@ -183,6 +183,9 @@ class NostrClientConnection: 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]) From 9501286c1f696575a8c20a529eb00389296499c0 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 11:58:19 +0200 Subject: [PATCH 055/195] refactor: extract method `_set_client_callbacks()` --- client_manager.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client_manager.py b/client_manager.py index 4b77da7..bd3f279 100644 --- a/client_manager.py +++ b/client_manager.py @@ -29,15 +29,13 @@ class NostrClientManager: if not allow_connect: return False - setattr(client, "broadcast_event", self.broadcast_event) - def get_client_config() -> ClientConfig: - return self.get_relay_config(client.relay_id) - setattr(client, "get_client_config", get_client_config) + self._set_client_callbacks(client) self.clients(client.relay_id).append(client) return True + def remove_client(self, c: "NostrClientConnection"): self.clients(c.relay_id).remove(c) @@ -77,6 +75,12 @@ class NostrClientManager: return False #todo: NIP-42: AUTH return True + + def _set_client_callbacks(self, client): + setattr(client, "broadcast_event", self.broadcast_event) + def get_client_config() -> ClientConfig: + return self.get_relay_config(client.relay_id) + setattr(client, "get_client_config", get_client_config) class NostrClientConnection: From 24f803921ebc873dbc52959c433d882f6660cedf Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 12:00:01 +0200 Subject: [PATCH 056/195] refactor: rename variables --- client_manager.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/client_manager.py b/client_manager.py index bd3f279..736e4ce 100644 --- a/client_manager.py +++ b/client_manager.py @@ -21,17 +21,15 @@ class NostrClientManager: self._active_relays: dict = {} self._is_ready = False - async def add_client(self, client: "NostrClientConnection") -> bool: + async def add_client(self, c: "NostrClientConnection") -> bool: if not self._is_ready: await self.init_relays() - allow_connect = await self._allow_client(client) - if not allow_connect: + if not (await self._allow_client(c)): return False - self._set_client_callbacks(client) - - self.clients(client.relay_id).append(client) + self._set_client_callbacks(c) + self.clients(c.relay_id).append(c) return True From 4b96f65c853ee7a47942bf2a2ccf0447c518bb99 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 12:58:19 +0200 Subject: [PATCH 057/195] fix: delete relay --- client_manager.py | 4 ++-- views_api.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client_manager.py b/client_manager.py index 736e4ce..cecc92a 100644 --- a/client_manager.py +++ b/client_manager.py @@ -52,7 +52,8 @@ class NostrClientManager: async def disable_relay(self, relay_id: str): await self._stop_clients_for_relay(relay_id) - del self._active_relays[relay_id] + if relay_id in self._active_relays: + del self._active_relays[relay_id] def get_relay_config(self, relay_id: str) -> RelayConfig: return self._active_relays[relay_id] @@ -93,7 +94,6 @@ class NostrClientConnection: await self.websocket.accept() while True: json_data = await self.websocket.receive_text() - print("### received: ", json_data) try: data = json.loads(json_data) diff --git a/views_api.py b/views_api.py index 5ffb88c..7ff8587 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 client_manager, nostrrelay_ext +from . import nostrrelay_ext from .client_manager import NostrClientConnection, NostrClientManager from .crud import ( create_relay, From 2cb9d083c6e180723e6542965bbbf3c34d0c7ef2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 15:07:53 +0200 Subject: [PATCH 058/195] feat: send message back to owner --- client_manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client_manager.py b/client_manager.py index cecc92a..3762091 100644 --- a/client_manager.py +++ b/client_manager.py @@ -39,8 +39,7 @@ class NostrClientManager: async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): for client in self.clients(source.relay_id): - if client != source: - await client.notify_event(event) + await client.notify_event(event) async def init_relays(self): self._active_relays = await get_config_for_all_active_relays() From b4094ad2f5001d3d4c710ab4b73c7744a11a88d0 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 17:51:35 +0200 Subject: [PATCH 059/195] feat: limit the number of filters a client can have --- client_manager.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client_manager.py b/client_manager.py index 3762091..12d9d1f 100644 --- a/client_manager.py +++ b/client_manager.py @@ -198,6 +198,9 @@ class NostrClientConnection: async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id self.remove_filter(subscription_id) + if self._can_add_filter(): + return [["NOTICE", f"Maximum number of filters ({self.client_config.max_client_filters}) exceeded."]] + self.filters.append(filter) events = await get_events(self.relay_id, filter) serialized_events = [ @@ -207,8 +210,11 @@ class NostrClientConnection: 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) - def remove_filter(self, subscription_id: str): - self.filters = [f for f in self.filters if f.subscription_id != subscription_id] + def _can_add_filter(self) -> bool: + return self.client_config.max_client_filters != 0 and len(self.filters) >= self.client_config.max_client_filters \ No newline at end of file From bddab70677f3d990736f98fd69628d08a7909d0d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 8 Feb 2023 18:15:43 +0200 Subject: [PATCH 060/195] feat: enforce query limit on relay side --- client_manager.py | 3 ++- crud.py | 4 +-- models.py | 6 +++++ .../relay-details/relay-details.html | 26 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/client_manager.py b/client_manager.py index 12d9d1f..1b7047e 100644 --- a/client_manager.py +++ b/client_manager.py @@ -200,7 +200,8 @@ class NostrClientConnection: self.remove_filter(subscription_id) if self._can_add_filter(): return [["NOTICE", f"Maximum number of filters ({self.client_config.max_client_filters}) exceeded."]] - + + filter.enforce_limit(self.client_config.limit_per_filter) self.filters.append(filter) events = await get_events(self.relay_id, filter) serialized_events = [ diff --git a/crud.py b/crud.py index 9d36059..5f2cc04 100644 --- a/crud.py +++ b/crud.py @@ -96,7 +96,7 @@ async def create_event(relay_id: str, e: NostrEvent): await create_event_tags(relay_id, e.id, name, value, extra) async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> List[NostrEvent]: - values, query = build_select_events_query(relay_id, filter) + query, values = build_select_events_query(relay_id, filter) rows = await db.fetchall(query, tuple(values)) @@ -191,4 +191,4 @@ def build_select_events_query(relay_id:str, filter:NostrFilter): if filter.limit and filter.limit > 0: query += f" LIMIT {filter.limit}" - return values, query + return query, values diff --git a/models.py b/models.py index 57e639a..a9d80a9 100644 --- a/models.py +++ b/models.py @@ -10,8 +10,10 @@ from secp256k1 import PublicKey class ClientConfig(BaseModel): max_client_filters = Field(0, alias="maxClientFilters") + limit_per_filter = Field(1000, alias="limitPerFilter") 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: @@ -177,6 +179,10 @@ class NostrFilter(BaseModel): 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 = ?"] diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index ad36d51..1a79416 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -156,6 +156,32 @@
+
+
Limit per filter:
+
+ +
+
+ No Limit + + Maximum number of events to be returned in the initial query + (default 1000) + +
+
Max Filters (per client):
From 868e02d3c2f8bccc7bf5b3a74bd72e89b5748601 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 10:28:56 +0200 Subject: [PATCH 061/195] feat: limit max events per second --- client_manager.py | 30 ++++++++++-- models.py | 1 + .../relay-details/relay-details.html | 47 +++++++++++++++---- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/client_manager.py b/client_manager.py index 1b7047e..1d40d22 100644 --- a/client_manager.py +++ b/client_manager.py @@ -1,4 +1,5 @@ import json +import time from typing import Any, Awaitable, Callable, List, Optional from fastapi import WebSocket @@ -89,6 +90,9 @@ class NostrClientConnection: self.broadcast_event: Optional[Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]] = None self.get_client_config: Optional[Callable[[], ClientConfig]] = None + self._last_event_timestamp = 0 # in seconds + self._event_count_per_timestamp = 0 + async def start(self): await self.websocket.accept() while True: @@ -146,6 +150,11 @@ class NostrClientConnection: async def _handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["OK", e.id] + if self._exceeded_max_events_per_second(): + resp_nip20 += [False, f"Exceeded max events per second limit'!"] + await self._send_msg(resp_nip20) + return None + if not self.client_config.is_author_allowed(e.pubkey): resp_nip20 += [False, f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!"] await self._send_msg(resp_nip20) @@ -197,7 +206,7 @@ class NostrClientConnection: async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: filter.subscription_id = subscription_id - self.remove_filter(subscription_id) + self._remove_filter(subscription_id) if self._can_add_filter(): return [["NOTICE", f"Maximum number of filters ({self.client_config.max_client_filters}) exceeded."]] @@ -211,11 +220,24 @@ class NostrClientConnection: serialized_events.append(resp_nip15) return serialized_events - def remove_filter(self, subscription_id: str): + 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) + self._remove_filter(subscription_id) def _can_add_filter(self) -> bool: - return self.client_config.max_client_filters != 0 and len(self.filters) >= self.client_config.max_client_filters \ No newline at end of file + return self.client_config.max_client_filters != 0 and len(self.filters) >= self.client_config.max_client_filters + + def _exceeded_max_events_per_second(self) -> bool: + if self.client_config.max_events_per_second == 0: + return False + + current_time = round(time.time()) + 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.client_config.max_events_per_second diff --git a/models.py b/models.py index a9d80a9..b9da88d 100644 --- a/models.py +++ b/models.py @@ -11,6 +11,7 @@ from secp256k1 import PublicKey class ClientConfig(BaseModel): max_client_filters = Field(0, alias="maxClientFilters") limit_per_filter = Field(1000, alias="limitPerFilter") + max_events_per_second = Field(0, alias="maxEventsPerSecond") allowed_public_keys = Field([], alias="allowedPublicKeys") blocked_public_keys = Field([], alias="blockedPublicKeys") diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 1a79416..0b09ec4 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -168,18 +168,18 @@ >
+ + + Maximum number of events to be returned in the initial query + (default 1000) + No Limit - Maximum number of events to be returned in the initial query - (default 1000) -
@@ -194,14 +194,41 @@ >
- + + Limit the number of filters that a client can have. Prevents + relay from being abused by clients with extremly high number of + fiters. + + Unlimited Filters
+
+
Max events per second:
+
+ +
+
+ + + Limits the rate at which events are accepted by the relay. + Prevent clients from clogging the relay. + + No Limit + +
+
From f5c873ec4d7807cac2d3109bc558dcf54b269ef3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 12:18:54 +0200 Subject: [PATCH 062/195] feat: add support for `NIP22` --- client_manager.py | 19 +++- models.py | 23 +++- .../relay-details/relay-details.html | 102 ++++++++++++++++++ .../components/relay-details/relay-details.js | 17 +++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/client_manager.py b/client_manager.py index 1d40d22..a1add5d 100644 --- a/client_manager.py +++ b/client_manager.py @@ -1,6 +1,6 @@ import json import time -from typing import Any, Awaitable, Callable, List, Optional +from typing import Any, Awaitable, Callable, List, Optional, Tuple from fastapi import WebSocket from loguru import logger @@ -167,6 +167,12 @@ class NostrClientConnection: await self._send_msg(resp_nip20) return None + in_range, message = self._created_at_in_range(e.created_at) + if not in_range: + resp_nip20 += [False, message] + await self._send_msg(resp_nip20) + return None + try: if e.is_replaceable_event(): await delete_events( @@ -186,6 +192,7 @@ class NostrClientConnection: resp_nip20 += [event != None, message] await self._send_msg(resp_nip20) + @property def client_config(self) -> ClientConfig: @@ -241,3 +248,13 @@ class NostrClientConnection: self._event_count_per_timestamp = 0 return self._event_count_per_timestamp > self.client_config.max_events_per_second + + 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): + 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): + return False, "created_at is too much into the future" + return True, "" \ No newline at end of file diff --git a/models.py b/models.py index b9da88d..71c70d0 100644 --- a/models.py +++ b/models.py @@ -12,6 +12,17 @@ class ClientConfig(BaseModel): max_client_filters = Field(0, alias="maxClientFilters") limit_per_filter = Field(1000, alias="limitPerFilter") max_events_per_second = Field(0, alias="maxEventsPerSecond") + + 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") + allowed_public_keys = Field([], alias="allowedPublicKeys") blocked_public_keys = Field([], alias="blockedPublicKeys") @@ -23,7 +34,15 @@ class ClientConfig(BaseModel): return True # todo: check payment return p in self.allowed_public_keys - + + @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 Config: allow_population_by_field_name = True class RelayConfig(ClientConfig): @@ -55,7 +74,7 @@ class NostrRelay(BaseModel): def info(cls,) -> dict: return { "contact": "https://t.me/lnbits", - "supported_nips": [1, 9, 11, 15, 20], + "supported_nips": [1, 9, 11, 15, 20, 22], "software": "LNbits", "version": "", } diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 0b09ec4..e025652 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -156,6 +156,108 @@
+
+
Created At in Past:
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + NIP 22: Lower limit within which a relay will consider an + event's created_at to be acceptable. + +
+
+
+
Created At in Future:
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + NIP 22: Upper limit within which a relay will consider an + event's created_at to be acceptable. + +
+
Limit per filter:
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index 2eaf8fe..a76d28a 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -21,6 +21,23 @@ async function relayDetails(path) { } }, + computed: { + hours: function () { + const y = [] + for (let i = 0; i <= 24; i++) { + y.push(i) + } + return y + }, + range60: function () { + const y = [] + for (let i = 0; i <= 60; i++) { + y.push(i) + } + return y + } + }, + methods: { satBtc(val, showUnit = true) { return satOrBtc(val, showUnit, this.satsDenominated) From 50fd1942cc71910d5b68994c72dd00a5f762e0e5 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 12:26:37 +0200 Subject: [PATCH 063/195] refactor: extract method `_validate_event()` --- client_manager.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/client_manager.py b/client_manager.py index a1add5d..093fbf7 100644 --- a/client_manager.py +++ b/client_manager.py @@ -150,26 +150,9 @@ class NostrClientConnection: async def _handle_event(self, e: NostrEvent): resp_nip20: List[Any] = ["OK", e.id] - if self._exceeded_max_events_per_second(): - resp_nip20 += [False, f"Exceeded max events per second limit'!"] - await self._send_msg(resp_nip20) - return None - - if not self.client_config.is_author_allowed(e.pubkey): - resp_nip20 += [False, f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!"] - await self._send_msg(resp_nip20) - return None - - try: - e.check_signature() - except ValueError as ex: - resp_nip20 += [False, "invalid: wrong event `id` or `sig`"] - await self._send_msg(resp_nip20) - return None - - in_range, message = self._created_at_in_range(e.created_at) - if not in_range: - resp_nip20 += [False, message] + valid, message = self._validate_event(e) + if not valid: + resp_nip20 + [valid, message] await self._send_msg(resp_nip20) return None @@ -236,6 +219,24 @@ 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 + 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 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: + 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, "" + def _exceeded_max_events_per_second(self) -> bool: if self.client_config.max_events_per_second == 0: return False From cb81726297cc7ba1dda8ba3d4fecd5c846150653 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 14:33:19 +0200 Subject: [PATCH 064/195] feat: add UI for storage --- models.py | 5 + .../relay-details/relay-details.html | 222 ++++++++++++------ .../components/relay-details/relay-details.js | 9 + 3 files changed, 169 insertions(+), 67 deletions(-) diff --git a/models.py b/models.py index 71c70d0..898eb83 100644 --- a/models.py +++ b/models.py @@ -22,6 +22,11 @@ class ClientConfig(BaseModel): created_at_hours_future = Field(0, alias="createdAtHoursFuture") created_at_minutes_future = Field(0, alias="createdAtMinutesFuture") created_at_seconds_future = Field(0, alias="createdAtSecondsFuture") + + + free_storage_value = Field("1", alias="freeStorageValue") + free_storage_unit = Field("MB", alias="freeStorageUnit") + full_storage_action = Field("prune", alias="fullStorageAction") allowed_public_keys = Field([], alias="allowedPublicKeys") blocked_public_keys = Field([], alias="blockedPublicKeys") diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index e025652..de2c738 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -62,7 +62,39 @@
-
Require Payment:
+
Free storage:
+
+ +
+
+ +
+
+ No free storage + +
+
+ +
+
Paid Plan:
+ No data will be stored, only broadcast +
-
-
Cost to join (sats):
-
- + +
+
+
Cost to join (sats):
+
+ +
+
+ Free to join + +
-
- Free to join - -
-
-
-
Storage cost per Kilo-Byte (sats):
-
- -
-
- Unlimited storage - -
-
-
-
Free storage (kb):
-
- -
-
- No free storage - +
+
Storage cost per Kilo-Byte (sats):
+
+ +
+
+ Unlimited storage + +
+ +
Created At in Past:
@@ -258,6 +318,34 @@ >
+ +
+
Full Storage Action:
+
+ +
+
+ + + Action to be taken when the storage limit (if any) has been + reached. + + No Limit + +
+
Limit per filter:
diff --git a/static/components/relay-details/relay-details.js b/static/components/relay-details/relay-details.js index a76d28a..39755ca 100644 --- a/static/components/relay-details/relay-details.js +++ b/static/components/relay-details/relay-details.js @@ -35,6 +35,15 @@ async function relayDetails(path) { y.push(i) } return y + }, + storageUnits: function () { + return ['KB', 'MB'] + }, + fullStorageActions: function () { + return [ + {value: 'block', label: 'Block New Events'}, + {value: 'prune', label: 'Prune Old Events'} + ] } }, From f331a19d75af40820ced39a8575e4268a6d80daf Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 14:56:24 +0200 Subject: [PATCH 065/195] feat: payment UI updates --- models.py | 5 +- .../relay-details/relay-details.html | 155 +++++++++--------- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/models.py b/models.py index 898eb83..33f82fe 100644 --- a/models.py +++ b/models.py @@ -50,12 +50,15 @@ class ClientConfig(BaseModel): class Config: allow_population_by_field_name = True + class RelayConfig(ClientConfig): is_paid_relay = Field(False, alias="isPaidRelay") wallet = Field("") cost_to_join = Field(0, alias="costToJoin") free_storage = Field(0, alias="freeStorage") - storage_cost_per_kb = Field(0, alias="storageCostPerKb") + + storage_cost_value = Field(0, alias="storageCostValue") + storage_cost_unit = Field("MB", alias="storageCostUnit") class NostrRelay(BaseModel): diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index de2c738..a41107b 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -62,7 +62,7 @@
-
Free storage:
+
Free storage:
-
+
+ + + How much data a client can store. This can be extended with the + Paid Plan. + +
+
No free storage
@@ -106,59 +114,96 @@ No data will be stored, only broadcast - -
+
+
Wallet:
+
+ + +
+
+ + + Wallet where the paiments will be sent to. + +
+
Cost to join (sats):
-
+
+
+ + + Ask a fee for clients to join. Expected to be paid only once. + +
Free to join
-
Storage cost per Kilo-Byte (sats):
-
+
Storage Cost (sats):
+
-
+
+ +
+
+ + + Cost for clients to buy additional storage. + +
+
Unlimited storage
@@ -168,54 +213,6 @@
- -
Created At in Past:
@@ -338,12 +335,6 @@ reached. - No Limit -
@@ -367,7 +358,7 @@ No Limit
@@ -391,7 +382,10 @@ fiters. - Unlimited Filters
@@ -414,7 +408,10 @@ Prevent clients from clogging the relay. - No Limit
From c488f5d5e068dd4741157f0962b67e77a855f4cb Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 15:16:22 +0200 Subject: [PATCH 066/195] feat: store message size --- crud.py | 7 ++++--- migrations.py | 3 ++- models.py | 5 +++++ static/components/relay-details/relay-details.html | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crud.py b/crud.py index 5f2cc04..f67dd95 100644 --- a/crud.py +++ b/crud.py @@ -82,11 +82,12 @@ async def create_event(relay_id: str, e: NostrEvent): created_at, kind, content, - sig + sig, + size ) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (relay_id, e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig), + (relay_id, e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.size_bytes), ) # todo: optimize with bulk insert diff --git a/migrations.py b/migrations.py index bcf9074..e4837e8 100644 --- a/migrations.py +++ b/migrations.py @@ -28,7 +28,8 @@ async def m001_initial(db): created_at {db.big_int} NOT NULL, kind INT NOT NULL, content TEXT NOT NULL, - sig TEXT NOT NULL + sig TEXT NOT NULL, + size {db.big_int} DEFAULT 0 ); """ ) diff --git a/models.py b/models.py index 33f82fe..ce6074f 100644 --- a/models.py +++ b/models.py @@ -116,6 +116,11 @@ class NostrEvent(BaseModel): 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()) + def is_replaceable_event(self) -> bool: return self.kind in [0, 3] diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index a41107b..56d3efc 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -62,7 +62,7 @@
-
Free storage:
+
Free Storage:
Date: Thu, 9 Feb 2023 15:43:08 +0200 Subject: [PATCH 067/195] fix: UI on small screen --- .../relay-details/relay-details.html | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 56d3efc..d9f1fba 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -11,7 +11,7 @@
Name:
-
+
-
+
Description:
-
+
-
+
Relay Public Key:
-
+
-
+
Contact:
-
+
-
+
@@ -63,7 +63,7 @@
Free Storage:
-
+
-
+
-
+
Wallet:
-
+
-
+
Wallet where the paiments will be sent to. @@ -144,7 +144,7 @@
Cost to join (sats):
-
+
-
+
Storage Cost (sats):
-
+
-
+
-
+
Full Storage Action:
-
+
-
+
Action to be taken when the storage limit (if any) has been @@ -339,7 +339,7 @@
Limit per filter:
-
+
-
+
Maximum number of events to be returned in the initial query @@ -365,7 +365,7 @@
Max Filters (per client):
-
+
-
+
Limit the number of filters that a client can have. Prevents @@ -392,7 +392,7 @@
Max events per second:
-
+
-
+
Limits the rate at which events are accepted by the relay. From f109833a36b3351fe3cb3d998a013ce5fd27bf6e Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 9 Feb 2023 15:43:39 +0200 Subject: [PATCH 068/195] feat: `get_storage_for_public_key` --- crud.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crud.py b/crud.py index f67dd95..6b7a893 100644 --- a/crud.py +++ b/crud.py @@ -119,6 +119,15 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: event.tags = await get_event_tags(relay_id, id) return event +async def get_storage_for_public_key(relay_id: str, pubkey: str) -> int: + row = await db.fetchone("SELECT SUM(size) FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ?", (relay_id, pubkey,)) + if not row: + return 0 + + return row["sum"] + + + async def mark_events_deleted(relay_id: str, filter: NostrFilter): if filter.is_empty(): return None From 9fef72ae0ca1459f7902654cd0f0c27e6097e41c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 10:10:01 +0200 Subject: [PATCH 069/195] doc: add comment --- crud.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crud.py b/crud.py index 6b7a893..ec16bf0 100644 --- a/crud.py +++ b/crud.py @@ -120,6 +120,8 @@ 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: + """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) FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ?", (relay_id, pubkey,)) if not row: return 0 From d01084881ad0d635c8bd4feec9548019437dcdc3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 10:16:16 +0200 Subject: [PATCH 070/195] fix: UI layout --- .../relay-details/relay-details.html | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index d9f1fba..13fd21b 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -63,7 +63,7 @@
Free Storage:
-
+
-
+
-
+
Paid Plan:
-
+
Wallet:
-
+
-
+
Wallet where the paiments will be sent to. @@ -144,7 +144,7 @@
Cost to join (sats):
-
+
-
+
Storage Cost (sats):
-
+
-
+
-
+
Date: Fri, 10 Feb 2023 10:21:18 +0200 Subject: [PATCH 071/195] fix: message --- static/components/relay-details/relay-details.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/components/relay-details/relay-details.html b/static/components/relay-details/relay-details.html index 13fd21b..42ad9e5 100644 --- a/static/components/relay-details/relay-details.html +++ b/static/components/relay-details/relay-details.html @@ -115,7 +115,7 @@ v-if="!relay.config.isPaidRelay && relay.config.freeStorageValue == 0" color="orange" class="float-right q-mb-md" - >No data will be stored, only broadcast + >No data will be stored. Read-only relay.
From 339f2b70c1672db5cd5c7bb70ae2a16a8e1e8727 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 11:43:18 +0200 Subject: [PATCH 072/195] feat: restrict storage --- client_manager.py | 32 ++++++++++++++++++++++++++++++++ crud.py | 33 ++++++++++++++++++++++++++++----- models.py | 11 +++++++++-- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/client_manager.py b/client_manager.py index 093fbf7..70b3be5 100644 --- a/client_manager.py +++ b/client_manager.py @@ -11,7 +11,10 @@ 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, ) from .models import ClientConfig, NostrEvent, NostrEventType, NostrFilter, RelayConfig @@ -156,6 +159,12 @@ class NostrClientConnection: await self._send_msg(resp_nip20) return None + valid, message = await self._validate_storage(e) + if not valid: + resp_nip20 += [valid, message] + await self._send_msg(resp_nip20) + return None + try: if e.is_replaceable_event(): await delete_events( @@ -237,6 +246,29 @@ class NostrClientConnection: return True, "" + async def _validate_storage(self, e: NostrEvent) -> 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) + 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: + 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}'" + + await prune_old_events(self.relay_id, e.pubkey, e.size_bytes) + + return True, "" + def _exceeded_max_events_per_second(self) -> bool: if self.client_config.max_events_per_second == 0: return False diff --git a/crud.py b/crud.py index ec16bf0..87bd29c 100644 --- a/crud.py +++ b/crud.py @@ -1,7 +1,5 @@ import json -from typing import Any, List, Optional - -from lnbits.helpers import urlsafe_short_hash +from typing import Any, List, Optional, Tuple from . import db from .models import NostrEvent, NostrFilter, NostrRelay, RelayConfig @@ -121,13 +119,24 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: async def get_storage_for_public_key(relay_id: str, 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) FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ?", (relay_id, pubkey,)) if not row: return 0 - return row["sum"] + return round(row["sum"]) +async def get_prunable_events(relay_id: str, pubkey: str) -> List[Tuple[str, int]]: + """ Return the oldest 10 000 events. Only the `id` and the size are returned, so the data size should be small""" + query = """ + SELECT id, size FROM nostrrelay.events + WHERE relay_id = ? AND pubkey = ? + ORDER BY created_at ASC LIMIT 10000 + """ + + rows = await db.fetchall(query, (relay_id, pubkey)) + + return [(r["id"], r["size"]) for r in rows] async def mark_events_deleted(relay_id: str, filter: NostrFilter): @@ -146,6 +155,20 @@ async def delete_events(relay_id: str, filter: NostrFilter): await db.execute(query, tuple(values)) #todo: delete tags +async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int): + prunable_events = await get_prunable_events(relay_id, pubkey) + prunable_event_ids = [] + size = 0 + + for pe in prunable_events: + prunable_event_ids.append(pe[0]) + size += pe[1] + + if size > space_to_regain: + break + + await delete_events(relay_id, NostrFilter(ids=prunable_event_ids)) + async def delete_all_events(relay_id: str): query = "DELETE from nostrrelay.events WHERE relay_id = ?" diff --git a/models.py b/models.py index ce6074f..8ebd3dc 100644 --- a/models.py +++ b/models.py @@ -24,7 +24,8 @@ class ClientConfig(BaseModel): created_at_seconds_future = Field(0, alias="createdAtSecondsFuture") - free_storage_value = Field("1", alias="freeStorageValue") + is_paid_relay = Field(False, alias="isPaidRelay") + free_storage_value = Field(1, alias="freeStorageValue") free_storage_unit = Field("MB", alias="freeStorageUnit") full_storage_action = Field("prune", alias="fullStorageAction") @@ -48,11 +49,17 @@ class ClientConfig(BaseModel): 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 + @property + def free_storage_bytes_value(self): + value = self.free_storage_value * 1024 + if self.free_storage_unit == "MB": + value *= 1024 + return value + class Config: allow_population_by_field_name = True class RelayConfig(ClientConfig): - is_paid_relay = Field(False, alias="isPaidRelay") wallet = Field("") cost_to_join = Field(0, alias="costToJoin") free_storage = Field(0, alias="freeStorage") From 55f9142f3dcc0a8a83f266ae647a835d8e12bea1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 12:09:45 +0200 Subject: [PATCH 073/195] fix: query for SQLite --- crud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crud.py b/crud.py index 87bd29c..a7d2145 100644 --- a/crud.py +++ b/crud.py @@ -120,7 +120,7 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: async def get_storage_for_public_key(relay_id: str, 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) FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ?", (relay_id, pubkey,)) + row = await db.fetchone("SELECT SUM(size) as sum FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ? GROUP BY pubkey", (relay_id, pubkey,)) if not row: return 0 From 1eda457067be6f46fb42b9ec5cf77946e11df815 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 12:16:25 +0200 Subject: [PATCH 074/195] chore: force `py` format --- __init__.py | 4 +- client_manager.py | 61 ++++++++++++------- crud.py | 132 +++++++++++++++++++++++++++++++++--------- models.py | 37 ++++++++---- tests/helpers.py | 1 + tests/test_clients.py | 5 +- tests/test_events.py | 64 +++++++++++++------- views_api.py | 43 +++++++++----- 8 files changed, 248 insertions(+), 99 deletions(-) diff --git a/__init__.py b/__init__.py index 849456f..031464e 100644 --- a/__init__.py +++ b/__init__.py @@ -27,7 +27,7 @@ from .views import * # noqa from .views_api import * # noqa settings.lnbits_relay_information = { - "name": "LNbits Nostr Relay", + "name": "LNbits Nostr Relay", "description": "Multiple relays are supported", - **NostrRelay.info() + **NostrRelay.info(), } diff --git a/client_manager.py b/client_manager.py index 70b3be5..1c4cc53 100644 --- a/client_manager.py +++ b/client_manager.py @@ -37,7 +37,6 @@ class NostrClientManager: return True - def remove_client(self, c: "NostrClientConnection"): self.clients(c.relay_id).remove(c) @@ -60,7 +59,7 @@ class NostrClientManager: def get_relay_config(self, relay_id: str) -> RelayConfig: return self._active_relays[relay_id] - + def clients(self, relay_id: str) -> List["NostrClientConnection"]: if relay_id not in self._clients: self._clients[relay_id] = [] @@ -75,25 +74,29 @@ 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 + # todo: NIP-42: AUTH return True def _set_client_callbacks(self, client): setattr(client, "broadcast_event", self.broadcast_event) + def get_client_config() -> ClientConfig: return self.get_relay_config(client.relay_id) - setattr(client, "get_client_config", get_client_config) - -class NostrClientConnection: + setattr(client, "get_client_config", 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.broadcast_event: Optional[Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]] = None + self.broadcast_event: Optional[ + Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] + ] = None self.get_client_config: Optional[Callable[[], ClientConfig]] = None - self._last_event_timestamp = 0 # in seconds + self._last_event_timestamp = 0 # in seconds self._event_count_per_timestamp = 0 async def start(self): @@ -184,12 +187,11 @@ class NostrClientConnection: resp_nip20 += [event != None, message] await self._send_msg(resp_nip20) - @property def client_config(self) -> ClientConfig: if not self.get_client_config: - raise Exception("Client not ready!") + raise Exception("Client not ready!") return self.get_client_config() async def _send_msg(self, data: List): @@ -207,7 +209,12 @@ class NostrClientConnection: filter.subscription_id = subscription_id self._remove_filter(subscription_id) if self._can_add_filter(): - return [["NOTICE", f"Maximum number of filters ({self.client_config.max_client_filters}) exceeded."]] + return [ + [ + "NOTICE", + f"Maximum number of filters ({self.client_config.max_client_filters}) exceeded.", + ] + ] filter.enforce_limit(self.client_config.limit_per_filter) self.filters.append(filter) @@ -226,14 +233,20 @@ class NostrClientConnection: self._remove_filter(subscription_id) def _can_add_filter(self) -> bool: - return self.client_config.max_client_filters != 0 and len(self.filters) >= self.client_config.max_client_filters + return ( + self.client_config.max_client_filters != 0 + and len(self.filters) >= self.client_config.max_client_filters + ) - def _validate_event(self, e: NostrEvent)-> Tuple[bool, str]: + 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 not self.client_config.is_author_allowed(e.pubkey): - return False, f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!" + return ( + False, + f"Public key '{e.pubkey}' is not allowed in relay '{self.relay_id}'!", + ) try: e.check_signature() @@ -252,19 +265,21 @@ class NostrClientConnection: 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) 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: - return True, "" - + 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}'" - + return ( + False, + f"Cannot write event, no more storage available for public key: '{e.pubkey}'", + ) + await prune_old_events(self.relay_id, e.pubkey, e.size_bytes) return True, "" @@ -280,7 +295,9 @@ class NostrClientConnection: self._last_event_timestamp = current_time self._event_count_per_timestamp = 0 - return self._event_count_per_timestamp > self.client_config.max_events_per_second + return ( + self._event_count_per_timestamp > self.client_config.max_events_per_second + ) def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]: current_time = round(time.time()) @@ -290,4 +307,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, "" \ No newline at end of file + return True, "" diff --git a/crud.py b/crud.py index a7d2145..70d5dfa 100644 --- a/crud.py +++ b/crud.py @@ -6,18 +6,28 @@ from .models import NostrEvent, NostrFilter, NostrRelay, RelayConfig ########################## RELAYS #################### + async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.execute( """ INSERT INTO nostrrelay.relays (user_id, id, name, description, pubkey, contact, meta) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (user_id, r.id, r.name, r.description, r.pubkey, r.contact, json.dumps(dict(r.config))), + ( + user_id, + r.id, + r.name, + r.description, + r.pubkey, + r.contact, + json.dumps(dict(r.config)), + ), ) relay = await get_relay(user_id, r.id) assert relay, "Created relay cannot be retrieved" return relay + async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.execute( """ @@ -25,31 +35,59 @@ async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay: SET (name, description, pubkey, contact, active, meta) = (?, ?, ?, ?, ?, ?) WHERE user_id = ? AND id = ? """, - (r.name, r.description, r.pubkey, r.contact, r.active, json.dumps(dict(r.config)), user_id, r.id), + ( + r.name, + r.description, + r.pubkey, + r.contact, + r.active, + json.dumps(dict(r.config)), + user_id, + r.id, + ), ) - + return r + async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: - row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,)) + row = await db.fetchone( + """SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", + ( + user_id, + relay_id, + ), + ) return NostrRelay.from_row(row) if row else None + async def get_relays(user_id: str) -> List[NostrRelay]: - rows = await db.fetchall("""SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""", (user_id,)) + rows = await db.fetchall( + """SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""", + (user_id,), + ) return [NostrRelay.from_row(row) for row in rows] + async def get_config_for_all_active_relays() -> dict: - rows = await db.fetchall("SELECT id, meta FROM nostrrelay.relays WHERE active = true",) + rows = await db.fetchall( + "SELECT id, meta FROM nostrrelay.relays WHERE active = true", + ) active_relay_configs = {} for r in rows: - active_relay_configs[r["id"]] = RelayConfig(**json.loads(r["meta"])) #todo: from_json + active_relay_configs[r["id"]] = RelayConfig( + **json.loads(r["meta"]) + ) # todo: from_json return active_relay_configs + async def get_public_relay(relay_id: str) -> Optional[dict]: - row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)) + row = await db.fetchone( + """SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,) + ) if not row: return None @@ -59,14 +97,20 @@ async def get_public_relay(relay_id: str) -> Optional[dict]: **NostrRelay.info(), "id": relay.id, "name": relay.name, - "description":relay.description, - "pubkey":relay.pubkey, - "contact":relay.contact + "description": relay.description, + "pubkey": relay.pubkey, + "contact": relay.contact, } async def delete_relay(user_id: str, relay_id: str): - await db.execute("""DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,)) + await db.execute( + """DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", + ( + user_id, + relay_id, + ), + ) ########################## EVENTS #################### @@ -85,7 +129,16 @@ async def create_event(relay_id: str, e: NostrEvent): ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (relay_id, e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.size_bytes), + ( + relay_id, + e.id, + e.pubkey, + e.created_at, + e.kind, + e.content, + e.sig, + e.size_bytes, + ), ) # todo: optimize with bulk insert @@ -94,7 +147,10 @@ async def create_event(relay_id: str, e: NostrEvent): extra = json.dumps(rest) if rest else None await create_event_tags(relay_id, e.id, name, value, extra) -async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> List[NostrEvent]: + +async def get_events( + relay_id: str, filter: NostrFilter, include_tags=True +) -> List[NostrEvent]: query, values = build_select_events_query(relay_id, filter) rows = await db.fetchall(query, tuple(values)) @@ -108,8 +164,15 @@ async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> return events + async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: - row = await db.fetchone("SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", (relay_id, id,)) + row = await db.fetchone( + "SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", + ( + relay_id, + id, + ), + ) if not row: return None @@ -117,17 +180,25 @@ async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]: event.tags = await get_event_tags(relay_id, id) return event + async def get_storage_for_public_key(relay_id: str, 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", (relay_id, pubkey,)) + row = await db.fetchone( + "SELECT SUM(size) as sum FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ? GROUP BY pubkey", + ( + relay_id, + pubkey, + ), + ) if not row: return 0 return round(row["sum"]) + async def get_prunable_events(relay_id: str, pubkey: str) -> List[Tuple[str, int]]: - """ Return the oldest 10 000 events. Only the `id` and the size are returned, so the data size should be small""" + """Return the oldest 10 000 events. Only the `id` and the size are returned, so the data size should be small""" query = """ SELECT id, size FROM nostrrelay.events WHERE relay_id = ? AND pubkey = ? @@ -139,21 +210,26 @@ async def get_prunable_events(relay_id: str, pubkey: str) -> List[Tuple[str, int return [(r["id"], r["size"]) for r in rows] -async def mark_events_deleted(relay_id: str, filter: NostrFilter): +async def mark_events_deleted(relay_id: str, filter: NostrFilter): if filter.is_empty(): return None _, where, values = filter.to_sql_components(relay_id) - await db.execute(f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", tuple(values)) + await db.execute( + f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", + tuple(values), + ) -async def delete_events(relay_id: str, filter: NostrFilter): + +async def delete_events(relay_id: str, filter: NostrFilter): if filter.is_empty(): return None _, where, values = filter.to_sql_components(relay_id) query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}""" await db.execute(query, tuple(values)) - #todo: delete tags + # todo: delete tags + async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int): prunable_events = await get_prunable_events(relay_id, pubkey) @@ -175,8 +251,13 @@ async def delete_all_events(relay_id: str): await db.execute(query, (relay_id,)) # todo: delete tags + async def create_event_tags( - relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str] + relay_id: str, + event_id: str, + tag_name: str, + tag_value: str, + extra_values: Optional[str], ): await db.execute( """ @@ -192,9 +273,8 @@ async def create_event_tags( (relay_id, event_id, tag_name, tag_value, extra_values), ) -async def get_event_tags( - relay_id: str, event_id: str -) -> List[List[str]]: + +async def get_event_tags(relay_id: str, event_id: str) -> List[List[str]]: rows = await db.fetchall( "SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?", (relay_id, event_id), @@ -211,7 +291,7 @@ async def get_event_tags( return tags -def build_select_events_query(relay_id:str, filter:NostrFilter): +def build_select_events_query(relay_id: str, filter: NostrFilter): inner_joins, where, values = filter.to_sql_components(relay_id) query = f""" diff --git a/models.py b/models.py index 8ebd3dc..0eb4404 100644 --- a/models.py +++ b/models.py @@ -23,15 +23,13 @@ class ClientConfig(BaseModel): created_at_minutes_future = Field(0, alias="createdAtMinutesFuture") created_at_seconds_future = Field(0, alias="createdAtSecondsFuture") - is_paid_relay = Field(False, alias="isPaidRelay") free_storage_value = Field(1, alias="freeStorageValue") free_storage_unit = Field("MB", alias="freeStorageUnit") full_storage_action = Field("prune", alias="fullStorageAction") - + 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: @@ -43,11 +41,21 @@ class ClientConfig(BaseModel): @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 + 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 + return ( + self.created_at_days_future * 86400 + + self.created_at_hours_future * 3600 + + self.created_at_minutes_future * 60 + + self.created_at_seconds_future + ) @property def free_storage_bytes_value(self): @@ -59,6 +67,7 @@ class ClientConfig(BaseModel): class Config: allow_population_by_field_name = True + class RelayConfig(ClientConfig): wallet = Field("") cost_to_join = Field(0, alias="costToJoin") @@ -78,7 +87,6 @@ class NostrRelay(BaseModel): config: "RelayConfig" = RelayConfig() - @classmethod def from_row(cls, row: Row) -> "NostrRelay": relay = cls(**dict(row)) @@ -86,7 +94,9 @@ class NostrRelay(BaseModel): return relay @classmethod - def info(cls,) -> dict: + def info( + cls, + ) -> dict: return { "contact": "https://t.me/lnbits", "supported_nips": [1, 9, 11, 15, 20, 22], @@ -223,7 +233,9 @@ class NostrFilter(BaseModel): 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]]: + 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] @@ -231,13 +243,17 @@ class NostrFilter(BaseModel): 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") + 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") + 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: @@ -262,6 +278,5 @@ class NostrFilter(BaseModel): if self.until: where.append("created_at < ?") values += [self.until] - return inner_joins, where, values diff --git a/tests/helpers.py b/tests/helpers.py index 7d004bc..f818bc9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,6 +2,7 @@ import json FIXTURES_PATH = "tests/extensions/nostrrelay/fixture" + def get_fixtures(file): """ Read the content of the JSON file. diff --git a/tests/test_clients.py b/tests/test_clients.py index 73469eb..1ef7212 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -40,9 +40,7 @@ class MockWebSocket(WebSocket): async def wire_mock_data(self, data: dict): await self.fake_wire.put(dumps(data)) - async def close( - self, code: int = 1000, reason: Optional[str] = None - ) -> None: + async def close(self, code: int = 1000, reason: Optional[str] = None) -> None: logger.info(reason) @@ -152,7 +150,6 @@ async def bob_wires_contact_list(ws_alice: MockWebSocket, ws_bob: MockWebSocket) await ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"]) await asyncio.sleep(0.1) - print("### ws_alice.sent_message", ws_alice.sent_messages) print("### ws_bob.sent_message", ws_bob.sent_messages) diff --git a/tests/test_events.py b/tests/test_events.py index 0793204..0e6c296 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -12,6 +12,7 @@ from .helpers import get_fixtures RELAY_ID = "r1" + class EventFixture(BaseModel): name: str exception: Optional[str] @@ -23,6 +24,7 @@ def valid_events() -> List[EventFixture]: data = get_fixtures("events") return [EventFixture.parse_obj(e) for e in data["valid"]] + @pytest.fixture def invalid_events() -> List[EventFixture]: data = get_fixtures("events") @@ -37,6 +39,7 @@ def test_valid_event_id_and_signature(valid_events: List[EventFixture]): logger.error(f"Invalid 'id' ot 'signature' for fixture: '{f.name}'") raise e + def test_invalid_event_id_and_signature(invalid_events: List[EventFixture]): for f in invalid_events: with pytest.raises(ValueError, match=f.exception): @@ -44,7 +47,7 @@ def test_invalid_event_id_and_signature(invalid_events: List[EventFixture]): @pytest.mark.asyncio -async def test_valid_event_crud(valid_events: List[EventFixture]): +async def test_valid_event_crud(valid_events: List[EventFixture]): author = "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211" event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96" reply_event_id = "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894" @@ -54,12 +57,10 @@ async def test_valid_event_crud(valid_events: List[EventFixture]): for e in all_events: await create_event(RELAY_ID, e) - - for f in valid_events: + for f in valid_events: await get_by_id(f.data, f.name) await filter_by_id(all_events, f.data, f.name) - await filter_by_author(all_events, author) await filter_by_tag_p(all_events, author) @@ -70,22 +71,30 @@ async def test_valid_event_crud(valid_events: List[EventFixture]): await filter_by_tag_e_p_and_author(all_events, author, event_id, reply_event_id) + async def get_by_id(data: NostrEvent, test_name: str): event = await get_event(RELAY_ID, data.id) assert event, f"Failed to restore event (id='{data.id}')" - assert event.json() != json.dumps(data.json()), f"Restored event is different for fixture '{test_name}'" + assert event.json() != json.dumps( + data.json() + ), f"Restored event is different for fixture '{test_name}'" + async def filter_by_id(all_events: List[NostrEvent], data: NostrEvent, test_name: str): filter = NostrFilter(ids=[data.id]) events = await get_events(RELAY_ID, filter) assert len(events) == 1, f"Expected one queried event '{test_name}'" - assert events[0].json() != json.dumps(data.json()), f"Queried event is different for fixture '{test_name}'" + assert events[0].json() != json.dumps( + data.json() + ), f"Queried event is different for fixture '{test_name}'" filtered_events = [e for e in all_events if filter.matches(e)] assert len(filtered_events) == 1, f"Expected one filter event '{test_name}'" - assert filtered_events[0].json() != json.dumps(data.json()), f"Filtered event is different for fixture '{test_name}'" - + assert filtered_events[0].json() != json.dumps( + data.json() + ), f"Filtered event is different for fixture '{test_name}'" + async def filter_by_author(all_events: List[NostrEvent], author): filter = NostrFilter(authors=[author]) @@ -95,9 +104,10 @@ async def filter_by_author(all_events: List[NostrEvent], author): filtered_events = [e for e in all_events if filter.matches(e)] assert len(filtered_events) == 5, f"Failed to filter by authors" + async def filter_by_tag_p(all_events: List[NostrEvent], author): # todo: check why constructor does not work for fields with aliases (#e, #p) - filter = NostrFilter() + filter = NostrFilter() filter.p.append(author) events_related_to_author = await get_events(RELAY_ID, filter) @@ -107,7 +117,7 @@ async def filter_by_tag_p(all_events: List[NostrEvent], author): assert len(filtered_events) == 5, f"Failed to filter by tag 'p'" -async def filter_by_tag_e(all_events: List[NostrEvent], event_id): +async def filter_by_tag_e(all_events: List[NostrEvent], event_id): filter = NostrFilter() filter.e.append(event_id) @@ -117,29 +127,43 @@ async def filter_by_tag_e(all_events: List[NostrEvent], event_id): filtered_events = [e for e in all_events if filter.matches(e)] assert len(filtered_events) == 2, f"Failed to filter by tag 'e'" -async def filter_by_tag_e_and_p(all_events: List[NostrEvent], author, event_id, reply_event_id): + +async def filter_by_tag_e_and_p( + all_events: List[NostrEvent], author, event_id, reply_event_id +): filter = NostrFilter() filter.p.append(author) filter.e.append(event_id) - + events_related_to_event = await get_events(RELAY_ID, filter) assert len(events_related_to_event) == 1, f"Failed to quert by tags 'e' & 'p'" - assert events_related_to_event[0].id == reply_event_id, f"Failed to query the right event by tags 'e' & 'p'" + assert ( + events_related_to_event[0].id == reply_event_id + ), f"Failed to query the right event by tags 'e' & 'p'" filtered_events = [e for e in all_events if filter.matches(e)] assert len(filtered_events) == 1, f"Failed to filter by tags 'e' & 'p'" - assert filtered_events[0].id == reply_event_id, f"Failed to find the right event by tags 'e' & 'p'" + assert ( + filtered_events[0].id == reply_event_id + ), f"Failed to find the right event by tags 'e' & 'p'" -async def filter_by_tag_e_p_and_author(all_events: List[NostrEvent], author, event_id, reply_event_id): + +async def filter_by_tag_e_p_and_author( + all_events: List[NostrEvent], author, event_id, reply_event_id +): filter = NostrFilter(authors=[author]) filter.p.append(author) filter.e.append(event_id) events_related_to_event = await get_events(RELAY_ID, filter) - assert len(events_related_to_event) == 1, f"Failed to query by 'author' and tags 'e' & 'p'" - assert events_related_to_event[0].id == reply_event_id, f"Failed to query the right event by 'author' and tags 'e' & 'p'" + assert ( + len(events_related_to_event) == 1 + ), f"Failed to query by 'author' and tags 'e' & 'p'" + assert ( + events_related_to_event[0].id == reply_event_id + ), f"Failed to query the right event by 'author' and tags 'e' & 'p'" filtered_events = [e for e in all_events if filter.matches(e)] assert len(filtered_events) == 1, f"Failed to filter by 'author' and tags 'e' & 'p'" - assert filtered_events[0].id == reply_event_id, f"Failed to filter the right event by 'author' and tags 'e' & 'p'" - - + assert ( + filtered_events[0].id == reply_event_id + ), f"Failed to filter the right event by 'author' and tags 'e' & 'p'" diff --git a/views_api.py b/views_api.py index 7ff8587..a41fe85 100644 --- a/views_api.py +++ b/views_api.py @@ -30,6 +30,7 @@ from .models import NostrRelay client_manager = NostrClientManager() + @nostrrelay_ext.websocket("/{relay_id}") async def websocket_endpoint(relay_id: str, websocket: WebSocket): client = NostrClientConnection(relay_id=relay_id, websocket=websocket) @@ -44,7 +45,6 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket): client_manager.remove_client(client) - @nostrrelay_ext.get("/{relay_id}", status_code=HTTPStatus.OK) async def api_nostrrelay_info(relay_id: str): relay = await get_public_relay(relay_id) @@ -54,16 +54,20 @@ async def api_nostrrelay_info(relay_id: str): detail="Relay not found", ) - return JSONResponse(content=relay, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Allow-Methods": "GET" - }) - + return JSONResponse( + content=relay, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "GET", + }, + ) @nostrrelay_ext.post("/api/v1/relay") -async def api_create_relay(data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay: +async def api_create_relay( + data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> NostrRelay: if len(data.id): await check_admin(UUID4(wallet.wallet.user)) else: @@ -80,8 +84,11 @@ async def api_create_relay(data: NostrRelay, wallet: WalletTypeInfo = Depends(re detail="Cannot create relay", ) + @nostrrelay_ext.put("/api/v1/relay/{relay_id}") -async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay: +async def api_update_relay( + relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> NostrRelay: if relay_id != data.id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -97,8 +104,8 @@ async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeIn ) updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)}) updated_relay = await update_relay(wallet.wallet.user, updated_relay) - - if updated_relay.active: + + if updated_relay.active: await client_manager.enable_relay(relay_id, updated_relay.config) else: await client_manager.disable_relay(relay_id) @@ -116,7 +123,9 @@ async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeIn @nostrrelay_ext.get("/api/v1/relay") -async def api_get_relays(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> List[NostrRelay]: +async def api_get_relays( + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> List[NostrRelay]: try: return await get_relays(wallet.wallet.user) except Exception as ex: @@ -126,8 +135,11 @@ async def api_get_relays(wallet: WalletTypeInfo = Depends(require_invoice_key)) detail="Cannot fetch relays", ) + @nostrrelay_ext.get("/api/v1/relay/{relay_id}") -async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)) -> Optional[NostrRelay]: +async def api_get_relay( + relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key) +) -> Optional[NostrRelay]: try: relay = await get_relay(wallet.wallet.user, relay_id) except Exception as ex: @@ -143,8 +155,11 @@ async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_ ) return relay + @nostrrelay_ext.delete("/api/v1/relay/{relay_id}") -async def api_delete_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)): +async def api_delete_relay( + relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): try: await client_manager.disable_relay(relay_id) await delete_relay(wallet.wallet.user, relay_id) From 9ccd94aae30273ac7feb6593f770934868e5b462 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 13:39:38 +0200 Subject: [PATCH 075/195] feat: basic relay public info --- views.py | 34 +++++++++++++++++++++++++--------- views_api.py | 19 ------------------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/views.py b/views.py index de769f3..e68923d 100644 --- a/views.py +++ b/views.py @@ -1,9 +1,13 @@ +from http import HTTPStatus + from fastapi import Depends, Request +from fastapi.exceptions import HTTPException from fastapi.templating import Jinja2Templates -from starlette.responses import HTMLResponse +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 @@ -17,13 +21,25 @@ async def index(request: Request, user: User = Depends(check_user_exists)): ) -@nostrrelay_ext.get("/public") -async def nostrrelay(request: Request, nostrrelay_id): +@nostrrelay_ext.get("/{relay_id}") +async def nostrrelay(request: Request, relay_id: str): + relay_public_data = await get_public_relay(relay_id) + if not relay_public_data: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Cannot find relay", + ) + + 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 nostrrelay_renderer().TemplateResponse( - "nostrrelay/public.html", - { - "request": request, - # "nostrrelay": relay, - "web_manifest": f"/nostrrelay/manifest/{nostrrelay_id}.webmanifest", - }, + "nostrrelay/public.html", {"request": request, "relay": relay_public_data} ) diff --git a/views_api.py b/views_api.py index a41fe85..f883b77 100644 --- a/views_api.py +++ b/views_api.py @@ -45,25 +45,6 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket): client_manager.remove_client(client) -@nostrrelay_ext.get("/{relay_id}", status_code=HTTPStatus.OK) -async def api_nostrrelay_info(relay_id: str): - relay = await get_public_relay(relay_id) - if not relay: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Relay not found", - ) - - return JSONResponse( - content=relay, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Allow-Methods": "GET", - }, - ) - - @nostrrelay_ext.post("/api/v1/relay") async def api_create_relay( data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key) From 0c72f868edf2e310df09fee5f41ac689666efc0b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 13:40:01 +0200 Subject: [PATCH 076/195] fix: do not use `lnbits_relay_information` --- __init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/__init__.py b/__init__.py index 031464e..7da5850 100644 --- a/__init__.py +++ b/__init__.py @@ -26,8 +26,8 @@ from .models import NostrRelay from .views import * # noqa from .views_api import * # noqa -settings.lnbits_relay_information = { - "name": "LNbits Nostr Relay", - "description": "Multiple relays are supported", - **NostrRelay.info(), -} +# settings.lnbits_relay_information = { +# "name": "LNbits Nostr Relay", +# "description": "Multiple relays are supported", +# **NostrRelay.info(), +# } From d5aff477179c05395092e6806e22377ece22c43b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 13:40:15 +0200 Subject: [PATCH 077/195] refactor: class renamings --- client_manager.py | 12 ++++---- crud.py | 4 +-- models.py | 47 ++++++++++++++++++-------------- templates/nostrrelay/public.html | 2 +- tests/test_clients.py | 4 +-- 5 files changed, 37 insertions(+), 32 deletions(-) diff --git a/client_manager.py b/client_manager.py index 1c4cc53..8b4ac2c 100644 --- a/client_manager.py +++ b/client_manager.py @@ -16,7 +16,7 @@ from .crud import ( mark_events_deleted, prune_old_events, ) -from .models import ClientConfig, NostrEvent, NostrEventType, NostrFilter, RelayConfig +from .models import NostrEvent, NostrEventType, NostrFilter, RelaySpec class NostrClientManager: @@ -48,7 +48,7 @@ class NostrClientManager: self._active_relays = await get_config_for_all_active_relays() self._is_ready = True - async def enable_relay(self, relay_id: str, config: RelayConfig): + async def enable_relay(self, relay_id: str, config: RelaySpec): self._is_ready = True self._active_relays[relay_id] = config @@ -57,7 +57,7 @@ class NostrClientManager: if relay_id in self._active_relays: del self._active_relays[relay_id] - def get_relay_config(self, relay_id: str) -> RelayConfig: + def get_relay_config(self, relay_id: str) -> RelaySpec: return self._active_relays[relay_id] def clients(self, relay_id: str) -> List["NostrClientConnection"]: @@ -80,7 +80,7 @@ class NostrClientManager: def _set_client_callbacks(self, client): setattr(client, "broadcast_event", self.broadcast_event) - def get_client_config() -> ClientConfig: + def get_client_config() -> RelaySpec: return self.get_relay_config(client.relay_id) setattr(client, "get_client_config", get_client_config) @@ -94,7 +94,7 @@ class NostrClientConnection: self.broadcast_event: Optional[ Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] ] = None - self.get_client_config: Optional[Callable[[], ClientConfig]] = None + self.get_client_config: Optional[Callable[[], RelaySpec]] = None self._last_event_timestamp = 0 # in seconds self._event_count_per_timestamp = 0 @@ -189,7 +189,7 @@ class NostrClientConnection: await self._send_msg(resp_nip20) @property - def client_config(self) -> ClientConfig: + def client_config(self) -> RelaySpec: if not self.get_client_config: raise Exception("Client not ready!") return self.get_client_config() diff --git a/crud.py b/crud.py index 70d5dfa..8689a12 100644 --- a/crud.py +++ b/crud.py @@ -2,7 +2,7 @@ import json from typing import Any, List, Optional, Tuple from . import db -from .models import NostrEvent, NostrFilter, NostrRelay, RelayConfig +from .models import NostrEvent, NostrFilter, NostrRelay, RelaySpec ########################## RELAYS #################### @@ -77,7 +77,7 @@ async def get_config_for_all_active_relays() -> dict: ) active_relay_configs = {} for r in rows: - active_relay_configs[r["id"]] = RelayConfig( + active_relay_configs[r["id"]] = RelaySpec( **json.loads(r["meta"]) ) # todo: from_json diff --git a/models.py b/models.py index 0eb4404..51144b3 100644 --- a/models.py +++ b/models.py @@ -8,9 +8,11 @@ from pydantic import BaseModel, Field from secp256k1 import PublicKey -class ClientConfig(BaseModel): +class FilterSpec(BaseModel): max_client_filters = Field(0, alias="maxClientFilters") limit_per_filter = Field(1000, alias="limitPerFilter") + +class EventSpec(BaseModel): max_events_per_second = Field(0, alias="maxEventsPerSecond") created_at_days_past = Field(0, alias="createdAtDaysPast") @@ -23,21 +25,6 @@ class ClientConfig(BaseModel): created_at_minutes_future = Field(0, alias="createdAtMinutesFuture") created_at_seconds_future = Field(0, alias="createdAtSecondsFuture") - is_paid_relay = Field(False, alias="isPaidRelay") - free_storage_value = Field(1, alias="freeStorageValue") - free_storage_unit = Field("MB", alias="freeStorageUnit") - full_storage_action = Field("prune", alias="fullStorageAction") - - 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 @property def created_at_in_past(self) -> int: @@ -57,6 +44,11 @@ class ClientConfig(BaseModel): + self.created_at_seconds_future ) +class StorageSpec(BaseModel): + 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 @@ -64,11 +56,20 @@ class ClientConfig(BaseModel): value *= 1024 return value - class Config: - allow_population_by_field_name = True +class AuthorSpec(BaseModel): + 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 RelayConfig(ClientConfig): +class PaymentSpec(BaseModel): + is_paid_relay = Field(False, alias="isPaidRelay") wallet = Field("") cost_to_join = Field(0, alias="costToJoin") free_storage = Field(0, alias="freeStorage") @@ -76,6 +77,10 @@ class RelayConfig(ClientConfig): storage_cost_value = Field(0, alias="storageCostValue") storage_cost_unit = Field("MB", alias="storageCostUnit") +class RelaySpec(FilterSpec, EventSpec, StorageSpec, AuthorSpec, PaymentSpec): + class Config: + allow_population_by_field_name = True + class NostrRelay(BaseModel): id: str @@ -85,12 +90,12 @@ class NostrRelay(BaseModel): contact: Optional[str] active: bool = False - config: "RelayConfig" = RelayConfig() + config: "RelaySpec" = RelaySpec() @classmethod def from_row(cls, row: Row) -> "NostrRelay": relay = cls(**dict(row)) - relay.config = RelayConfig(**json.loads(row["meta"])) + relay.config = RelaySpec(**json.loads(row["meta"])) return relay @classmethod diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index 5e349ee..e5e5cea 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -1,4 +1,4 @@ -{% extends "public.html" %} {% block toolbar_title %} {{ nostrrelay.name }} +{% extends "public.html" %} {% block toolbar_title %} {{ relay.name }} Date: Fri, 10 Feb 2023 13:41:55 +0200 Subject: [PATCH 078/195] chore: remove old field --- models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/models.py b/models.py index 51144b3..75cec3b 100644 --- a/models.py +++ b/models.py @@ -72,7 +72,6 @@ class PaymentSpec(BaseModel): is_paid_relay = Field(False, alias="isPaidRelay") wallet = Field("") cost_to_join = Field(0, alias="costToJoin") - free_storage = Field(0, alias="freeStorage") storage_cost_value = Field(0, alias="storageCostValue") storage_cost_unit = Field("MB", alias="storageCostUnit") From db3ad2e32fd4bc0d2d4b29f509dbbfed2b78ac2d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 14:47:48 +0200 Subject: [PATCH 079/195] feat: public relay page updates --- crud.py | 3 +- models.py | 22 +++++--- templates/nostrrelay/public.html | 96 ++++++++++++++++++++++++++++---- views.py | 1 + 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/crud.py b/crud.py index 8689a12..b945537 100644 --- a/crud.py +++ b/crud.py @@ -2,7 +2,7 @@ import json from typing import Any, List, Optional, Tuple from . import db -from .models import NostrEvent, NostrFilter, NostrRelay, RelaySpec +from .models import NostrEvent, NostrFilter, NostrRelay, RelayPublicSpec, RelaySpec ########################## RELAYS #################### @@ -100,6 +100,7 @@ async def get_public_relay(relay_id: str) -> Optional[dict]: "description": relay.description, "pubkey": relay.pubkey, "contact": relay.contact, + "config": dict(RelayPublicSpec(**dict(relay.config))) } diff --git a/models.py b/models.py index 75cec3b..44647d5 100644 --- a/models.py +++ b/models.py @@ -8,11 +8,14 @@ from pydantic import BaseModel, Field from secp256k1 import PublicKey -class FilterSpec(BaseModel): +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(BaseModel): +class EventSpec(Spec): max_events_per_second = Field(0, alias="maxEventsPerSecond") created_at_days_past = Field(0, alias="createdAtDaysPast") @@ -44,7 +47,7 @@ class EventSpec(BaseModel): + self.created_at_seconds_future ) -class StorageSpec(BaseModel): +class StorageSpec(Spec): free_storage_value = Field(1, alias="freeStorageValue") free_storage_unit = Field("MB", alias="freeStorageUnit") full_storage_action = Field("prune", alias="fullStorageAction") @@ -56,7 +59,7 @@ class StorageSpec(BaseModel): value *= 1024 return value -class AuthorSpec(BaseModel): +class AuthorSpec(Spec): allowed_public_keys = Field([], alias="allowedPublicKeys") blocked_public_keys = Field([], alias="blockedPublicKeys") @@ -68,18 +71,21 @@ class AuthorSpec(BaseModel): # todo: check payment return p in self.allowed_public_keys + class PaymentSpec(BaseModel): is_paid_relay = Field(False, alias="isPaidRelay") - wallet = Field("") 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): - class Config: - allow_population_by_field_name = True +class RelaySpec(FilterSpec, EventSpec, StorageSpec, AuthorSpec, PaymentSpec, WalletSpec): + pass +class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): + pass class NostrRelay(BaseModel): id: str diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index e5e5cea..ec65d42 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -1,16 +1,88 @@ -{% extends "public.html" %} {% block toolbar_title %} {{ relay.name }} - +{% extends "public.html" %} {% block toolbar_title %} LNbits Relay + {% endblock %} {% block footer %}{% endblock %} {% block page_container %} + -

Shareable public page on relay to go here!

+
+
+
+ + + +
+
+ + + + + + + + + + GET + /lnurlp/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+
+
+
+ + + + + + + + + + + + + + + +
+
+
+
+
{% endblock %} {% block scripts %} @@ -21,7 +93,9 @@ el: '#vue', mixins: [windowMixin], data: function () { - return {} + return { + relay: JSON.parse('{{relay | tojson | safe}}') + } }, methods: {} }) diff --git a/views.py b/views.py index e68923d..2f85ed6 100644 --- a/views.py +++ b/views.py @@ -24,6 +24,7 @@ async def index(request: Request, user: User = Depends(check_user_exists)): @nostrrelay_ext.get("/{relay_id}") async def nostrrelay(request: Request, relay_id: str): relay_public_data = await get_public_relay(relay_id) + if not relay_public_data: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, From 8678090e7b492dd4571095af0bf02e4831e62d08 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 17:20:55 +0200 Subject: [PATCH 080/195] feat: create invoice to join --- crud.py | 13 ++++- helpers.py | 19 +++++++ models.py | 9 ++++ templates/nostrrelay/public.html | 86 ++++++++++++++++++++++++++++++-- views_api.py | 57 +++++++++++++++++++-- 5 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 helpers.py diff --git a/crud.py b/crud.py index b945537..cf5b786 100644 --- a/crud.py +++ b/crud.py @@ -61,6 +61,17 @@ async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: return NostrRelay.from_row(row) if row else None +async def get_relay_by_id(relay_id: str) -> Optional[NostrRelay]: + """Note: it does not require `user_id`. Can read any relay. Use it with care.""" + row = await db.fetchone( + """SELECT * FROM nostrrelay.relays WHERE id = ?""", + ( + relay_id, + ), + ) + + return NostrRelay.from_row(row) if row else None + async def get_relays(user_id: str) -> List[NostrRelay]: rows = await db.fetchall( @@ -100,7 +111,7 @@ async def get_public_relay(relay_id: str) -> Optional[dict]: "description": relay.description, "pubkey": relay.pubkey, "contact": relay.contact, - "config": dict(RelayPublicSpec(**dict(relay.config))) + "config": RelayPublicSpec(**dict(relay.config)).dict(by_alias=True) } diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..fdff734 --- /dev/null +++ b/helpers.py @@ -0,0 +1,19 @@ +from bech32 import bech32_decode, convertbits + + +def normalize_public_key(pubkey: str) -> str: + if pubkey.startswith('npub1'): + _, decoded_data = bech32_decode(pubkey) + if not decoded_data: + raise ValueError("Public Key is not valid npub") + + decoded_data_bits = convertbits(decoded_data, 5, 8, False) + if not decoded_data_bits: + raise ValueError("Public Key is not valid npub") + return bytes(decoded_data_bits).hex() + + #check if valid hex + if len(pubkey) != 64: + raise ValueError("Public Key is not valid hex") + int(pubkey, 16) + return pubkey \ No newline at end of file diff --git a/models.py b/models.py index 44647d5..d9151fa 100644 --- a/models.py +++ b/models.py @@ -97,6 +97,10 @@ class NostrRelay(BaseModel): 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)) @@ -290,3 +294,8 @@ class NostrFilter(BaseModel): values += [self.until] return inner_joins, where, values + + +class RelayJoin(BaseModel): + relay_id: str + pubkey: str \ No newline at end of file diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index ec65d42..ffaf409 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -8,7 +8,55 @@
- + +

+
+
+ + + Public Key: + + + + Pay to join + Cost to join: + + + + sats + + + + + This is a free relay + + + + +
+ + + +
+
+
@@ -17,7 +65,7 @@ @@ -94,10 +142,40 @@ mixins: [windowMixin], data: function () { return { - relay: JSON.parse('{{relay | tojson | safe}}') + relay: JSON.parse('{{relay | tojson | safe}}'), + pubkey: '', + joinInvoice: '' } }, - methods: {} + methods: { + payToJoin: async function () { + if (!this.pubkey) { + this.$q.notify({ + timeout: 5000, + type: 'warning', + message: 'Public key is missing' + }) + return + } + try { + const {data} = await LNbits.api.request( + 'PUT', + '/nostrrelay/api/v1/join', + '', + { + relay_id: this.relay.id, + pubkey: this.pubkey + } + ) + this.joinInvoice = data.invoice + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created: function () { + console.log('### created', this.relay) + } }) {% endblock %} diff --git a/views_api.py b/views_api.py index f883b77..cb8c646 100644 --- a/views_api.py +++ b/views_api.py @@ -3,10 +3,10 @@ from typing import List, Optional from fastapi import Depends, WebSocket from fastapi.exceptions import HTTPException -from fastapi.responses import JSONResponse from loguru import logger from pydantic.types import UUID4 +from lnbits.core.services import create_invoice from lnbits.decorators import ( WalletTypeInfo, check_admin, @@ -21,12 +21,13 @@ from .crud import ( create_relay, delete_all_events, delete_relay, - get_public_relay, get_relay, + get_relay_by_id, get_relays, update_relay, ) -from .models import NostrRelay +from .helpers import normalize_public_key +from .models import NostrRelay, RelayJoin client_manager = NostrClientManager() @@ -151,3 +152,53 @@ async def api_delete_relay( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Cannot delete relay", ) + + +@nostrrelay_ext.put("/api/v1/join") +async def api_pay_to_join( + data: RelayJoin +): + + try: + pubkey = normalize_public_key(data.pubkey) + relay = await get_relay_by_id(data.relay_id) + if not relay: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Relay not found", + ) + + if relay.is_free_to_join: + raise ValueError("Relay is free to join") + + _, 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", + "relay": relay.id, + "pubkey": pubkey + }, + ) + print("### payment_request", payment_request) + return { + "invoice": payment_request + } + 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 invoice for client to join", + ) + + + From ebada934b0eee4bf2035983de1d2dbef93a9e708 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 10 Feb 2023 17:25:02 +0200 Subject: [PATCH 081/195] chore: code format --- crud.py | 7 +++---- helpers.py | 10 +++++----- models.py | 19 +++++++++++++++---- templates/nostrrelay/public.html | 2 +- views.py | 8 ++++---- views_api.py | 15 ++++----------- 6 files changed, 32 insertions(+), 29 deletions(-) diff --git a/crud.py b/crud.py index cf5b786..6a859b9 100644 --- a/crud.py +++ b/crud.py @@ -61,13 +61,12 @@ async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: return NostrRelay.from_row(row) if row else None + async def get_relay_by_id(relay_id: str) -> Optional[NostrRelay]: """Note: it does not require `user_id`. Can read any relay. Use it with care.""" row = await db.fetchone( """SELECT * FROM nostrrelay.relays WHERE id = ?""", - ( - relay_id, - ), + (relay_id,), ) return NostrRelay.from_row(row) if row else None @@ -111,7 +110,7 @@ async def get_public_relay(relay_id: str) -> Optional[dict]: "description": relay.description, "pubkey": relay.pubkey, "contact": relay.contact, - "config": RelayPublicSpec(**dict(relay.config)).dict(by_alias=True) + "config": RelayPublicSpec(**dict(relay.config)).dict(by_alias=True), } diff --git a/helpers.py b/helpers.py index fdff734..bcf5c02 100644 --- a/helpers.py +++ b/helpers.py @@ -2,18 +2,18 @@ from bech32 import bech32_decode, convertbits def normalize_public_key(pubkey: str) -> str: - if pubkey.startswith('npub1'): + if pubkey.startswith("npub1"): _, decoded_data = bech32_decode(pubkey) if not decoded_data: raise ValueError("Public Key is not valid npub") decoded_data_bits = convertbits(decoded_data, 5, 8, False) - if not decoded_data_bits: + if not decoded_data_bits: raise ValueError("Public Key is not valid npub") return bytes(decoded_data_bits).hex() - - #check if valid hex + + # check if valid hex if len(pubkey) != 64: raise ValueError("Public Key is not valid hex") int(pubkey, 16) - return pubkey \ No newline at end of file + return pubkey diff --git a/models.py b/models.py index d9151fa..47c3dde 100644 --- a/models.py +++ b/models.py @@ -11,10 +11,13 @@ from secp256k1 import PublicKey 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_second = Field(0, alias="maxEventsPerSecond") @@ -28,7 +31,6 @@ class EventSpec(Spec): 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 ( @@ -47,6 +49,7 @@ class EventSpec(Spec): + self.created_at_seconds_future ) + class StorageSpec(Spec): free_storage_value = Field(1, alias="freeStorageValue") free_storage_unit = Field("MB", alias="freeStorageUnit") @@ -59,6 +62,7 @@ class StorageSpec(Spec): value *= 1024 return value + class AuthorSpec(Spec): allowed_public_keys = Field([], alias="allowedPublicKeys") blocked_public_keys = Field([], alias="blockedPublicKeys") @@ -78,15 +82,22 @@ class PaymentSpec(BaseModel): 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): + +class RelaySpec( + FilterSpec, EventSpec, StorageSpec, AuthorSpec, PaymentSpec, WalletSpec +): pass + class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec): pass + class NostrRelay(BaseModel): id: str name: str @@ -99,7 +110,7 @@ class NostrRelay(BaseModel): @property def is_free_to_join(self): - return not self.config.is_paid_relay or self.config.cost_to_join == 0 + return not self.config.is_paid_relay or self.config.cost_to_join == 0 @classmethod def from_row(cls, row: Row) -> "NostrRelay": @@ -298,4 +309,4 @@ class NostrFilter(BaseModel): class RelayJoin(BaseModel): relay_id: str - pubkey: str \ No newline at end of file + pubkey: str diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html index ffaf409..1a7a96b 100644 --- a/templates/nostrrelay/public.html +++ b/templates/nostrrelay/public.html @@ -42,7 +42,7 @@ diff --git a/views.py b/views.py index 2f85ed6..0dedab6 100644 --- a/views.py +++ b/views.py @@ -24,12 +24,12 @@ async def index(request: Request, user: User = Depends(check_user_exists)): @nostrrelay_ext.get("/{relay_id}") async def nostrrelay(request: Request, relay_id: str): relay_public_data = await get_public_relay(relay_id) - + if not relay_public_data: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Cannot find relay", - ) + status_code=HTTPStatus.NOT_FOUND, + detail="Cannot find relay", + ) if request.headers.get("accept") == "application/nostr+json": return JSONResponse( diff --git a/views_api.py b/views_api.py index cb8c646..e4ad15f 100644 --- a/views_api.py +++ b/views_api.py @@ -155,10 +155,8 @@ async def api_delete_relay( @nostrrelay_ext.put("/api/v1/join") -async def api_pay_to_join( - data: RelayJoin -): - +async def api_pay_to_join(data: RelayJoin): + try: pubkey = normalize_public_key(data.pubkey) relay = await get_relay_by_id(data.relay_id) @@ -179,13 +177,11 @@ async def api_pay_to_join( "tag": "nostrrely", "action": "join", "relay": relay.id, - "pubkey": pubkey + "pubkey": pubkey, }, ) print("### payment_request", payment_request) - return { - "invoice": payment_request - } + return {"invoice": payment_request} except ValueError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -199,6 +195,3 @@ async def api_pay_to_join( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Cannot create invoice for client to join", ) - - - From b7840ced089bb30a40e97d80ee2adfaf984a579d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 13 Feb 2023 10:10:55 +0200 Subject: [PATCH 082/195] 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 083/195] 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 084/195] 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 085/195] 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 086/195] 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 087/195] 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 088/195] 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 089/195] 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 090/195] 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 091/195] 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 092/195] 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 093/195] 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 094/195] 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 095/195] 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 096/195] 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 097/195] 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 098/195] 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 099/195] 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 100/195] 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 101/195] 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 102/195] 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 103/195] 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 104/195] 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 105/195] 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 106/195] 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 107/195] 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 108/195] 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 109/195] 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 110/195] 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 111/195] 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 112/195] 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 113/195] 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 114/195] 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 115/195] 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 116/195] 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 117/195] 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 118/195] 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 119/195] 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 120/195] 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 121/195] 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 122/195] 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 123/195] 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 124/195] 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 125/195] 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 126/195] 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 127/195] 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 128/195] 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 129/195] 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 130/195] 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 131/195] 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 132/195] 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 133/195] 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 134/195] 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 135/195] 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 136/195] 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 137/195] 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 138/195] 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 139/195] 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 140/195] 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 141/195] 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 142/195] 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 143/195] 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 144/195] 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 145/195] 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 146/195] 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 147/195] 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 148/195] 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 149/195] 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 150/195] 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 151/195] 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 152/195] 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 153/195] 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 154/195] 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 155/195] 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 156/195] 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 157/195] 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 158/195] 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 159/195] 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 160/195] 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 161/195] 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 162/195] 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 @@ >