This commit is contained in:
callebtc 2023-02-10 14:55:30 +01:00
commit 69bf22c9ec
21 changed files with 778 additions and 0 deletions

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Nostr
Opens a Nostr daemon

36
__init__.py Normal file
View file

@ -0,0 +1,36 @@
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_nostrclient")
nostrclient_static_files = [
{
"path": "/nostrclient/static",
"app": StaticFiles(directory="lnbits/extensions/nostrclient/static"),
"name": "nostrclient_static",
}
]
nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"])
def nostr_renderer():
return template_renderer(["lnbits/extensions/nostrclient/templates"])
from .views import * # noqa
from .views_api import * # noqa
from .tasks import init_relays, subscribe_events
def nostrclient_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(init_relays))
# loop.create_task(catch_everything_and_restart(send_data))
# loop.create_task(catch_everything_and_restart(receive_data))
loop.create_task(catch_everything_and_restart(subscribe_events))

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

26
cbc.py Normal file
View file

@ -0,0 +1,26 @@
from Cryptodome.Cipher import AES
BLOCK_SIZE = 16
class AESCipher(object):
"""This class is compatible with crypto.createCipheriv('aes-256-cbc')"""
def __init__(self, key=None):
self.key = key
def pad(self, data):
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
return data + (chr(length) * length).encode()
def unpad(self, data):
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
def encrypt(self, plain_text):
cipher = AES.new(self.key, AES.MODE_CBC)
b = plain_text.encode("UTF-8")
return cipher.iv, cipher.encrypt(self.pad(b))
def decrypt(self, iv, enc_text):
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
return self.unpad(cipher.decrypt(enc_text).decode("UTF-8"))

6
config.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "Nostr Client",
"short_description": "Nostr client for extensions",
"tile": "/nostr-client/static/images/nostr-bitcoin.png",
"contributors": ["calle"]
}

29
crud.py Normal file
View file

@ -0,0 +1,29 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
import shortuuid
from . import db
from .models import Relay, RelayList
async def get_relays() -> RelayList:
row = await db.fetchall("SELECT * FROM nostradmin.relays")
return RelayList(__root__=row)
async def add_relay(relay: Relay) -> None:
await db.execute(
f"""
INSERT INTO nostradmin.relays (
id,
url,
active
)
VALUES (?, ?, ?)
""",
(relay.id, relay.url, relay.active),
)
async def delete_relay(relay: Relay) -> None:
await db.execute("DELETE FROM nostradmin.relays WHERE id = ?", (relay.id,))

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"repos": [
{
"id": "nostr-client",
"organisation": "lnbits",
"repository": "nostr-client-extension"
}
]
}

13
migrations.py Normal file
View file

@ -0,0 +1,13 @@
async def m001_initial(db):
"""
Initial nostrclient table.
"""
await db.execute(
f"""
CREATE TABLE nostrclient.relays (
id TEXT NOT NULL PRIMARY KEY,
url TEXT NOT NULL,
active BOOLEAN DEFAULT true
);
"""
)

92
models.py Normal file
View file

@ -0,0 +1,92 @@
from typing import List, Dict
from typing import Optional
from fastapi import Request
from pydantic import BaseModel, Field
from fastapi.param_functions import Query
from dataclasses import dataclass
from lnbits.helpers import urlsafe_short_hash
class Relay(BaseModel):
id: Optional[str] = None
url: Optional[str] = None
connected: Optional[bool] = None
connected_string: Optional[str] = None
status: Optional[str] = None
active: Optional[bool] = None
ping: Optional[int] = None
def _init__(self):
if not self.id:
self.id = urlsafe_short_hash()
class RelayList(BaseModel):
__root__: List[Relay]
class Event(BaseModel):
content: str
pubkey: str
created_at: Optional[int]
kind: int
tags: Optional[List[List[str]]]
sig: str
class Filter(BaseModel):
ids: Optional[List[str]]
kinds: Optional[List[int]]
authors: Optional[List[str]]
since: Optional[int]
until: Optional[int]
e: Optional[List[str]] = Field(alias="#e")
p: Optional[List[str]] = Field(alias="#p")
limit: Optional[int]
class Filters(BaseModel):
__root__: List[Filter]
# class nostrKeys(BaseModel):
# pubkey: str
# privkey: str
# class nostrNotes(BaseModel):
# id: str
# pubkey: str
# created_at: str
# kind: int
# tags: str
# content: str
# sig: str
# class nostrCreateRelays(BaseModel):
# relay: str = Query(None)
# class nostrCreateConnections(BaseModel):
# pubkey: str = Query(None)
# relayid: str = Query(None)
# class nostrRelays(BaseModel):
# id: Optional[str]
# relay: Optional[str]
# status: Optional[bool] = False
# class nostrRelaySetList(BaseModel):
# allowlist: Optional[str]
# denylist: Optional[str]
# class nostrConnections(BaseModel):
# id: str
# pubkey: Optional[str]
# relayid: Optional[str]
# class nostrSubscriptions(BaseModel):
# id: str
# userPubkey: Optional[str]
# subscribedPubkey: Optional[str]

1
nostr Submodule

@ -0,0 +1 @@
Subproject commit f598039e440f1d57c3b5d993ff44473649ffac3d

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

88
tasks.py Normal file
View file

@ -0,0 +1,88 @@
import asyncio
import ssl
import threading
from .nostr.nostr.client.client import NostrClient
from .nostr.nostr.event import Event
from .nostr.nostr.key import PublicKey
from .nostr.nostr.relay_manager import RelayManager
# relays = [
# "wss://nostr.mom",
# "wss://nostr-pub.wellorder.net",
# "wss://nostr.zebedee.cloud",
# "wss://relay.damus.io",
# "wss://relay.nostr.info",
# "wss://nostr.onsats.org",
# "wss://nostr-relay.untethr.me",
# "wss://relay.snort.social",
# "wss://lnbits.link/nostrrelay/client",
# ]
client = NostrClient(
connect=False,
)
# client = NostrClient(
# connect=False,
# privatekey_hex="211aac75a687ad96cca402406f8147a2726e31c5fc838e22ce30640ca1f3a6fe",
# )
received_event_queue: asyncio.Queue[Event] = asyncio.Queue(0)
from .crud import get_relays
async def init_relays():
relays = await get_relays()
client.relays = [r.url for r in relays.__root__]
client.connect()
return
# async def send_data():
# while not any([r.connected for r in client.relay_manager.relays.values()]):
# print("no relays connected yet")
# await asyncio.sleep(0.5)
# while True:
# client.dm("test", PublicKey(bytes.fromhex(client.public_key.hex())))
# print("sent DM")
# await asyncio.sleep(5)
# return
# async def receive_data():
# while not any([r.connected for r in client.relay_manager.relays.values()]):
# print("no relays connected yet")
# await asyncio.sleep(0.5)
# def callback(event: Event, decrypted_content=None):
# print(
# f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content or event.content}"
# )
# t = threading.Thread(
# target=client.get_dm,
# args=(
# client.public_key,
# callback,
# ),
# name="Nostr DM",
# )
# t.start()
async def subscribe_events():
while not any([r.connected for r in client.relay_manager.relays.values()]):
print("no relays connected yet")
await asyncio.sleep(1)
def callback(event: Event):
print(f"From {event.public_key[:3]}..{event.public_key[-3:]}: {event.content}")
asyncio.run(received_event_queue.put(event))
t = threading.Thread(
target=client.subscribe,
args=(callback,),
name="Nostr-event-subscription",
)
t.start()

View file

@ -0,0 +1,257 @@
{% 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-7 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">nostr relays</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
:filter="filter"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<!-- <q-th auto-width></q-th> -->
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>
<div v-if="col.value == true" style="background-color: green">
{{ col.value }}
</div>
<div v-else>{{ col.value }}</div>
</div>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteRelay(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<!-- <q-tabs
v-model="listSelection"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab name="denylist" label="Deny List"></q-tab>
<q-tab name="allowlist" label="Allow List"></q-tab>
</q-tabs> -->
<q-separator></q-separator>
<q-form class="q-gutter-md q-y-md" @submit="addRelay">
<div class="row">
<div class="col q-mx-lg q-my-sm">
<q-input v-model="relayToAdd" dense filled autogrow></q-input>
</div>
<div class="col q-mx-lg items-align flex items-center justify-right">
<q-btn unelevated color="primary" type="submit"> Add relay </q-btn>
</div>
</div>
</q-form>
</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}} Nostr Extension</h6>
<p>Only Admin users can manage this extension</p>
<q-card-section></q-card-section>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
var maplrelays = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
obj.ping = obj.ping + ' ms'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
relayToAdd: '',
nostrrelayLinks: [],
filter: '',
relayTable: {
columns: [
{
name: 'connected_string',
align: 'left',
label: '',
field: 'connected_string'
},
{
name: 'relay',
align: 'left',
label: 'URL',
field: 'url'
},
{
name: 'status',
align: 'center',
label: 'Status',
field: 'status'
},
{
name: 'ping',
align: 'center',
label: 'Ping',
field: 'ping'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
methods: {
getRelays: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostradmin/api/v1/relays',
self.g.user.wallets[0].adminkey
)
.then(function (response) {
if (response.data) {
console.log(response.data)
response.data.map(maplrelays)
self.nostrrelayLinks = response.data
console.log(self.nostrrelayLinks)
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
addRelay: function () {
console.log("ADD RELAY " + this.relayToAdd)
var self = this
LNbits.api
.request(
'POST',
'/nostradmin/api/v1/relay',
self.g.user.wallets[0].adminkey,
{url:this.relayToAdd},
)
.then(function (response) {
if (response.data) {
console.log(response.data)
response.data.map(maplrelays)
self.nostrrelayLinks = response.data
console.log(self.nostrrelayLinks)
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteRelay: function (relay_id) {
console.log("DELETE RELAY " + relay_id)
var self = this
LNbits.api
.request(
'DELETE',
'/nostradmin/api/v1/relay',
self.g.user.wallets[0].adminkey,
{id:relay_id},
)
.then(function (response) {
if (response.data) {
console.log(response.data)
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
exportlnurldeviceCSV: function () {
var self = this
LNbits.utils.exportCSV(self.relayTable.columns, this.nostrLinks)
}
},
created: function () {
var self = this
this.getRelays()
}
})
</script>
{% endblock %}

100
views.py Normal file
View file

@ -0,0 +1,100 @@
from http import HTTPStatus
import asyncio
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from . import nostrclient_ext, nostr_renderer
# FastAPI good for incoming
from fastapi import Request
from lnbits.core.crud import update_payment_status
from lnbits.core.models import User
from lnbits.core.views.api import api_payment
from lnbits.decorators import check_user_exists, check_admin
templates = Jinja2Templates(directory="templates")
@nostrclient_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_admin)):
return nostr_renderer().TemplateResponse(
"nostrclient/index.html", {"request": request, "user": user.dict()}
)
# #####################################################################
# #################### NOSTR WEBSOCKET THREAD #########################
# ##### THE QUEUE LOOP THREAD THING THAT LISTENS TO BUNCH OF ##########
# ### WEBSOCKET CONNECTIONS, STORING DATA IN DB/PUSHING TO FRONTEND ###
# ################### VIA updater() FUNCTION ##########################
# #####################################################################
# websocket_queue = asyncio.Queue(1000)
# # while True:
# async def nostr_subscribe():
# return
# # for the relays:
# # async with websockets.connect("ws://localhost:8765") as websocket:
# # for the public keys:
# # await websocket.send("subscribe to events")
# # await websocket.recv()
# #####################################################################
# ################### LNBITS WEBSOCKET ROUTES #########################
# #### HERE IS WHERE LNBITS FRONTEND CAN RECEIVE AND SEND MESSAGES ####
# #####################################################################
# class ConnectionManager:
# def __init__(self):
# self.active_connections: List[WebSocket] = []
# async def connect(self, websocket: WebSocket, nostr_id: str):
# await websocket.accept()
# websocket.id = nostr_id
# self.active_connections.append(websocket)
# def disconnect(self, websocket: WebSocket):
# self.active_connections.remove(websocket)
# async def send_personal_message(self, message: str, nostr_id: str):
# for connection in self.active_connections:
# if connection.id == nostr_id:
# await connection.send_text(message)
# async def broadcast(self, message: str):
# for connection in self.active_connections:
# await connection.send_text(message)
# manager = ConnectionManager()
# @nostrclient_ext.websocket("/nostrclient/ws/relayevents/{nostr_id}", name="nostr_id.websocket_by_id")
# async def websocket_endpoint(websocket: WebSocket, nostr_id: str):
# await manager.connect(websocket, nostr_id)
# try:
# while True:
# data = await websocket.receive_text()
# except WebSocketDisconnect:
# manager.disconnect(websocket)
# async def updater(nostr_id, message):
# copilot = await get_copilot(nostr_id)
# if not copilot:
# return
# await manager.send_personal_message(f"{message}", nostr_id)
# async def relay_check(relay: str):
# async with websockets.connect(relay) as websocket:
# if str(websocket.state) == "State.OPEN":
# print(str(websocket.state))
# return True
# else:
# return False

118
views_api.py Normal file
View file

@ -0,0 +1,118 @@
from http import HTTPStatus
import asyncio
import ssl
import json
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from sse_starlette.sse import EventSourceResponse
from . import nostrclient_ext
from .tasks import client, received_event_queue
from .crud import get_relays, add_relay, delete_relay
from .models import RelayList, Relay, Event, Filter, Filters
from .nostr.nostr.event import Event as NostrEvent
from .nostr.nostr.event import EncryptedDirectMessage
from .nostr.nostr.filter import Filter as NostrFilter
from .nostr.nostr.filter import Filters as NostrFilters
from .nostr.nostr.message_type import ClientMessageType
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
check_admin,
)
from lnbits.helpers import urlsafe_short_hash
from .tasks import init_relays
@nostrclient_ext.get("/api/v1/relays")
async def api_get_relays(): # type: ignore
relays = RelayList(__root__=[])
for url, r in client.relay_manager.relays.items():
status_text = (
f"⬆️ {r.num_sent_events} ⬇️ {r.num_received_events} ⚠️ {r.error_counter}"
)
connected_text = "🟢" if r.connected else "🔴"
relay_id = urlsafe_short_hash()
relays.__root__.append(
Relay(
id=relay_id,
url=url,
connected_string=connected_text,
status=status_text,
ping=r.ping,
connected=True,
active=True,
)
)
return relays
@nostrclient_ext.post("/api/v1/relay")
async def api_add_relay(relay: Relay): # type: ignore
assert relay.url, "no URL"
relay.id = urlsafe_short_hash()
await add_relay(relay)
await init_relays()
@nostrclient_ext.delete("/api/v1/relay")
async def api_delete_relay(relay: Relay): # type: ignore
await delete_relay(relay)
@nostrclient_ext.post("/api/v1/publish")
async def api_post_event(event: Event):
nostr_event = NostrEvent(
content=event.content,
public_key=event.pubkey,
created_at=event.created_at, # type: ignore
kind=event.kind,
tags=event.tags or None, # type: ignore
signature=event.sig,
)
client.relay_manager.publish_event(nostr_event)
@nostrclient_ext.post("/api/v1/filter")
async def api_subscribe(filter: Filter):
nostr_filter = NostrFilter(
event_ids=filter.ids,
kinds=filter.kinds, # type: ignore
authors=filter.authors,
since=filter.since,
until=filter.until,
event_refs=filter.e,
pubkey_refs=filter.p,
limit=filter.limit,
)
filters = NostrFilters([nostr_filter])
subscription_id = urlsafe_short_hash()
client.relay_manager.add_subscription(subscription_id, filters)
request = [ClientMessageType.REQUEST, subscription_id]
request.extend(filters.to_json_array())
message = json.dumps(request)
client.relay_manager.publish_message(message)
async def event_getter():
while True:
event = await received_event_queue.get()
if filters.match(event):
yield event.to_message()
return EventSourceResponse(
event_getter(),
ping=20,
media_type="text/event-stream",
)