feat: extracted
This commit is contained in:
parent
462770be40
commit
4b82905f78
12 changed files with 573 additions and 0 deletions
12
README.md
Normal file
12
README.md
Normal 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
27
__init__.py
Normal 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
94
client_manager.py
Normal 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
6
config.json
Normal 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
79
crud.py
Normal 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
38
migrations.py
Normal 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
116
models.py
Normal 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
BIN
static/image/nostrrelay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
108
templates/nostrrelay/index.html
Normal file
108
templates/nostrrelay/index.html
Normal 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 %}
|
||||
29
templates/nostrrelay/public.html
Normal file
29
templates/nostrrelay/public.html
Normal 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
32
views.py
Normal 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
32
views_api.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue