diff --git a/README.md b/README.md new file mode 100644 index 0000000..d98b0e2 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Nostr Relay + +## 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 + +1. Enable extension +2. Enable relay diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c95847e --- /dev/null +++ b/__init__.py @@ -0,0 +1,27 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_nostrrelay") + +nostrrelay_ext: APIRouter = APIRouter(prefix="/nostrrelay", tags=["NostrRelay"]) + +nostrrelay_static_files = [ + { + "path": "/nostrrelay/static", + "app": StaticFiles(directory="lnbits/extensions/nostrrelay/static"), + "name": "nostrrelay_static", + } +] + + +def nostrrelay_renderer(): + return template_renderer(["lnbits/extensions/nostrrelay/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/client_manager.py b/client_manager.py new file mode 100644 index 0000000..514c256 --- /dev/null +++ b/client_manager.py @@ -0,0 +1,94 @@ +import json +from typing import Callable, List + +from fastapi import WebSocket +from loguru import logger + +from .crud import create_event, get_events +from .models import NostrEvent, NostrEventType, NostrFilter + + +class NostrClientManager: + def __init__(self): + self.clients: List["NostrClientConnection"] = [] + + def add_client(self, client: "NostrClientConnection"): + setattr(client, "broadcast_event", self.broadcast_event) + self.clients.append(client) + print("### client count:", len(self.clients)) + + def remove_client(self, client: "NostrClientConnection"): + self.clients.remove(client) + + async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent): + print("### broadcast_event", len(self.clients)) + for client in self.clients: + if client != source: + await client.notify_event(event) + + +class NostrClientConnection: + broadcast_event: Callable + + def __init__(self, websocket: WebSocket): + self.websocket = websocket + self.filters: List[NostrFilter] = [] + + 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) + if resp: + for r in resp: + # print("### start send content: ", json.dumps(r)) + await self.websocket.send_text(json.dumps(r)) + except Exception as e: + logger.warning(e) + + async def notify_event(self, event: NostrEvent): + for filter in self.filters: + if filter.matches(event): + r = [NostrEventType.EVENT, filter.subscription_id, dict(event)] + print("### notify send content: ", json.dumps(r)) + await self.websocket.send_text(json.dumps(r)) + + async def __handle_message(self, data: List): + if len(data) < 2: + return + + message_type = data[0] + if message_type == NostrEventType.EVENT: + return await self.__handle_event(NostrEvent.parse_obj(data[1])) + 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: + return self.__handle_close(data[1]) + + async def __handle_event(self, e: "NostrEvent") -> None: + # print('### __handle_event', e) + e.check_signature() + await create_event("111", e) + await self.broadcast_event(self, e) + + 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) + return [ + [NostrEventType.EVENT, subscription_id, dict(event)] for event in events + ] + + def __handle_close(self, subscription_id: str) -> None: + print("### __handle_close", len(self.filters), subscription_id) + self.remove_filter(subscription_id) + print("### __handle_close", len(self.filters)) + + def remove_filter(self, subscription_id: str): + self.filters = [f for f in self.filters if f.subscription_id != subscription_id] diff --git a/config.json b/config.json new file mode 100644 index 0000000..e3de9fa --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "name": "Nostr Relay", + "short_description": "One click launch your own relay!", + "tile": "/nostrrelay/static/image/nostrrelay.png", + "contributors": ["arcbtc", "DCs"] +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..f01bc01 --- /dev/null +++ b/crud.py @@ -0,0 +1,79 @@ +from typing import Any, List + +from . import db +from .models import NostrEvent, NostrFilter + + +async def create_event(relay_id: str, e: NostrEvent): + await db.execute( + """ + INSERT INTO nostrrelay.events ( + relay_id, + id, + pubkey, + created_at, + kind, + content, + sig + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (relay_id, e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig), + ) + + # todo: optimize with bulk insert + for tag in e.tags: + await create_event_tags(relay_id, e.id, tag[0], tag[1]) + + +async def create_event_tags( + relay_id: str, event_id: str, tag_name: str, tag_value: str +): + await db.execute( + """ + INSERT INTO nostrrelay.event_tags ( + relay_id, + event_id, + name, + value + ) + VALUES (?, ?, ?, ?) + """, + (relay_id, event_id, tag_name, tag_value), + ) + + +async def get_events(relay_id: str, filter: NostrFilter) -> List[NostrEvent]: + query = "SELECT * FROM nostrrelay.events WHERE relay_id = ?" + values: List[Any] = [relay_id] + if len(filter.ids) != 0: + ids = ",".join(["?"] * len(filter.ids)) + query += f" AND id IN ({ids})" + values += filter.ids + if len(filter.authors) != 0: + authors = ",".join(["?"] * len(filter.authors)) + query += f" AND pubkey IN ({authors})" + values += filter.authors + if len(filter.kinds) != 0: + kinds = ",".join(["?"] * len(filter.kinds)) + query += f" AND kind IN ({kinds})" + values += filter.kinds + if filter.since: + query += f" AND created_at >= ?" + values += [filter.since] + if filter.until: + query += f" 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}" + + # print("### query: ", query) + # print("### values: ", tuple(values)) + rows = await db.fetchall(query, tuple(values)) + events = [NostrEvent.from_row(row) for row in rows] + + # print("### events: ", len(events)) + + return events diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..34b4390 --- /dev/null +++ b/migrations.py @@ -0,0 +1,38 @@ +async def m001_initial(db): + """ + Initial nostrrelays tables. + """ + await db.execute( + """ + CREATE TABLE nostrrelay.relays ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE nostrrelay.events ( + relay_id TEXT NOT NULL, + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + created_at {db.big_int} NOT NULL, + kind INT NOT NULL, + content TEXT NOT NULL, + sig TEXT NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE nostrrelay.event_tags ( + relay_id TEXT NOT NULL, + event_id TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL + ); + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..435c6e9 --- /dev/null +++ b/models.py @@ -0,0 +1,116 @@ +import hashlib +import json +from enum import Enum +from sqlite3 import Row +from typing import List, Optional + +from pydantic import BaseModel, Field +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 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=(",", ":")) + + @property + def event_id(self) -> str: + data = self.serialize_json() + id = hashlib.sha256(data.encode()).hexdigest() + return id + + 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}'") + + @classmethod + def from_row(cls, row: Row) -> "NostrEvent": + return cls(**dict(row)) + + +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): + tag_values = [t[1] for t in event_tags if t[0] == tag_name] + if len(tag_values) == 0: + return True + common_tags = [t for t in tag_values if t in self.e] + if len(common_tags) == 0: + return False + + + +class NostrEventType(str, Enum): + EVENT = "EVENT" + REQ = "REQ" + CLOSE = "CLOSE" diff --git a/static/image/nostrrelay.png b/static/image/nostrrelay.png new file mode 100644 index 0000000..8cbcf00 Binary files /dev/null and b/static/image/nostrrelay.png differ diff --git a/templates/nostrrelay/index.html b/templates/nostrrelay/index.html new file mode 100644 index 0000000..c8145d2 --- /dev/null +++ b/templates/nostrrelay/index.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
Disable relay
+
Enable relay
+
+
+ + + +
WebSocket Chat
+ + + +
Send
+
+ +
    +
    +
    +
    + +
    + + +
    + {{SITE_TITLE}} NostrRelay 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. +
    +
    +
    +
    +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/templates/nostrrelay/public.html b/templates/nostrrelay/public.html new file mode 100644 index 0000000..5e349ee --- /dev/null +++ b/templates/nostrrelay/public.html @@ -0,0 +1,29 @@ +{% extends "public.html" %} {% block toolbar_title %} {{ nostrrelay.name }} + +{% endblock %} {% block footer %}{% endblock %} {% block page_container %} + + +

    Shareable public page on relay to go here!

    +
    +
    +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..5d66220 --- /dev/null +++ b/views.py @@ -0,0 +1,32 @@ +from http import HTTPStatus + +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import nostrrelay_ext, nostrrelay_renderer + +templates = Jinja2Templates(directory="templates") + + +@nostrrelay_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return nostrrelay_renderer().TemplateResponse( + "nostrrelay/index.html", {"request": request, "user": user.dict()} + ) + + +@nostrrelay_ext.get("/public") +async def nostrrelay(request: Request, nostrrelay_id): + return nostrrelay_renderer().TemplateResponse( + "nostrrelay/public.html", + { + "request": request, + # "nostrrelay": relay, + "web_manifest": f"/nostrrelay/manifest/{nostrrelay_id}.webmanifest", + }, + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..78bdd46 --- /dev/null +++ b/views_api.py @@ -0,0 +1,32 @@ +from http import HTTPStatus + +from fastapi import Depends, Query, WebSocket +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import nostrrelay_ext +from .client_manager import NostrClientConnection, NostrClientManager + +client_manager = NostrClientManager() + + +@nostrrelay_ext.websocket("/client") +async def websocket_endpoint(websocket: WebSocket): + client = NostrClientConnection(websocket=websocket) + client_manager.add_client(client) + try: + await client.start() + except Exception as e: + logger.warning(e) + client_manager.remove_client(client) + + +@nostrrelay_ext.get("/api/v1/enable", status_code=HTTPStatus.OK) +async def api_nostrrelay(enable: bool = Query(True)): + return await enable_relay(enable) + + +async def enable_relay(enable: bool): + return enable