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