feat: extracted

This commit is contained in:
Vlad Stan 2023-01-30 14:40:55 +02:00
parent 462770be40
commit 4b82905f78
12 changed files with 573 additions and 0 deletions

12
README.md Normal file
View file

@ -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 <a href="https://code.pobblelabs.org/fossil/nostr_relay/">nostr_relay</a>.
UI for diagnostics and management (key alow/ban lists, rate limiting) coming soon!
### Usage
1. Enable extension
2. Enable relay

27
__init__.py Normal file
View file

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

94
client_manager.py Normal file
View file

@ -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]

6
config.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "Nostr Relay",
"short_description": "One click launch your own relay!",
"tile": "/nostrrelay/static/image/nostrrelay.png",
"contributors": ["arcbtc", "DCs"]
}

79
crud.py Normal file
View file

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

38
migrations.py Normal file
View file

@ -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
);
"""
)

116
models.py Normal file
View file

@ -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"

BIN
static/image/nostrrelay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,108 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="enableRelay"
><div v-if="enabled">Disable relay</div>
<div v-else>Enable relay</div></q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<h6>WebSocket Chat</h6>
<input type="text" id="messageText" autocomplete="off" />
<q-btn unelevated color="primary" @click="sendMessage()"
><div>Send</div>
</q-btn>
<ul id="messages"></ul>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} NostrRelay extension
</h6>
</q-card-section>
<q-card-section>
<p>
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.
</p>
<small
>Created by
<a
class="text-secondary"
href="https://pypi.org/user/dcs/"
target="_blank"
>DCs</a
>,
<a
class="text-secondary"
href="https://github.com/benarc"
target="_blank"
>Ben Arc</a
>.</small
>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
enabled: false,
ws: null
}
},
methods: {
enableRelay: function () {
// var self = this
// LNbits.api
// .request(
// 'GET',
// '/nostrrelay/api/v1/nostrrelays?all_wallets=true',
// this.g.user.wallets[0].inkey
// )
// .then(function (response) {
// self.nostrrelays = response.data.map(function (obj) {
// return mapNostrRelay(obj)
// })
// })
this.enabled = !this.enabled
},
sendMessage: function (event) {
var input = document.getElementById('messageText')
this.ws.send(input.value)
input.value = ''
}
},
created: function () {
this.ws = new WebSocket('ws://localhost:5000/nostrrelay/client')
this.ws.onmessage = function (event) {
var messages = document.getElementById('messages')
var message = document.createElement('li')
var content = document.createTextNode(event.data)
message.appendChild(content)
messages.appendChild(message)
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "public.html" %} {% block toolbar_title %} {{ nostrrelay.name }}
<q-btn
flat
dense
size="md"
@click.prevent="urlDialog.show = true"
icon="share"
color="white"
></q-btn>
{% endblock %} {% block footer %}{% endblock %} {% block page_container %}
<q-page-container>
<q-page>
<h3>Shareable public page on relay to go here!</h3>
</q-page>
</q-page-container>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
},
methods: {}
})
</script>
{% endblock %}

32
views.py Normal file
View file

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

32
views_api.py Normal file
View file

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