Improve stability (#25)

* chore: increase log level

* feat: secure relays endpoint

* feat: add config for the extension

* chore: update `min_lnbits_version`

* chore: improve logging

* fix: decrypt logic

* chore: improve logs
This commit is contained in:
Vlad Stan 2024-01-22 13:46:22 +02:00 committed by GitHub
parent 16ae9d15a1
commit a119c3836a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 220 additions and 193 deletions

View file

@ -2,6 +2,6 @@
"name": "Nostr Client", "name": "Nostr Client",
"short_description": "Nostr client for extensions", "short_description": "Nostr client for extensions",
"tile": "/nostrclient/static/images/nostr-bitcoin.png", "tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle"], "contributors": ["calle", "motorina0"],
"min_lnbits_version": "0.11.0" "min_lnbits_version": "0.12.0"
} }

40
crud.py
View file

@ -1,7 +1,9 @@
from typing import List from typing import List, Optional
import json
from . import db from . import db
from .models import Relay from .models import Config, Relay
async def get_relays() -> List[Relay]: async def get_relays() -> List[Relay]:
@ -25,3 +27,37 @@ async def add_relay(relay: Relay) -> None:
async def delete_relay(relay: Relay) -> None: async def delete_relay(relay: Relay) -> None:
await db.execute("DELETE FROM nostrclient.relays WHERE url = ?", (relay.url,)) await db.execute("DELETE FROM nostrclient.relays WHERE url = ?", (relay.url,))
######################CONFIG#######################
async def create_config() -> Config:
config = Config()
await db.execute(
"""
INSERT INTO nostrclient.config (json_data)
VALUES (?)
""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def update_config(config: Config) -> Optional[Config]:
await db.execute(
"""UPDATE nostrclient.config SET json_data = ?""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def get_config() -> Optional[Config]:
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None

View file

@ -11,3 +11,15 @@ async def m001_initial(db):
); );
""" """
) )
async def m002_create_config_table(db):
"""
Allow the extension to persist and retrieve any number of config values.
"""
await db.execute(
"""CREATE TABLE nostrclient.config (
json_data TEXT NOT NULL
);"""
)

View file

@ -42,3 +42,8 @@ class TestMessageResponse(BaseModel):
private_key: str private_key: str
public_key: str public_key: str
event_json: str event_json: str
class Config(BaseModel):
private_ws: bool = True
public_ws: bool = False

View file

@ -72,6 +72,7 @@ class RelayManager:
def close_subscription(self, id: str): def close_subscription(self, id: str):
try: try:
logger.info(f"Closing subscription: '{id}'.")
with self._subscriptions_lock: with self._subscriptions_lock:
if id in self._cached_subscriptions: if id in self._cached_subscriptions:
self._cached_subscriptions.pop(id) self._cached_subscriptions.pop(id)

View file

@ -42,7 +42,7 @@ class NostrRouter:
pass pass
try: try:
await self.websocket.close() await self.websocket.close(reason="Websocket connection closed")
except Exception as _: except Exception as _:
pass pass
@ -113,7 +113,7 @@ class NostrRouter:
def _handle_notices(self): def _handle_notices(self):
while len(NostrRouter.received_subscription_notices): while len(NostrRouter.received_subscription_notices):
my_event = NostrRouter.received_subscription_notices.pop(0) my_event = NostrRouter.received_subscription_notices.pop(0)
logger.info(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']") logger.debug(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']")
# Note: we don't send it to the user because # Note: we don't send it to the user because
# we don't know who should receive it # we don't know who should receive it
nostr_client.relay_manager.handle_notice(my_event) nostr_client.relay_manager.handle_notice(my_event)
@ -136,6 +136,7 @@ class NostrRouter:
def _handle_client_req(self, json_data): def _handle_client_req(self, json_data):
subscription_id = json_data[1] subscription_id = json_data[1]
logger.info(f"New subscription: '{subscription_id}'")
subscription_id_rewritten = urlsafe_short_hash() subscription_id_rewritten = urlsafe_short_hash()
self.original_subscription_ids[subscription_id_rewritten] = subscription_id self.original_subscription_ids[subscription_id_rewritten] = subscription_id
filters = json_data[2:] filters = json_data[2:]
@ -154,5 +155,6 @@ class NostrRouter:
if subscription_id_rewritten: if subscription_id_rewritten:
self.original_subscription_ids.pop(subscription_id_rewritten) self.original_subscription_ids.pop(subscription_id_rewritten)
nostr_client.relay_manager.close_subscription(subscription_id_rewritten) nostr_client.relay_manager.close_subscription(subscription_id_rewritten)
logger.info(f"Unsubscribe from '{subscription_id_rewritten}'. Original id: '{subscription_id}.'")
else: else:
logger.debug(f"Failed to unsubscribe from '{subscription_id}.'") logger.info(f"Failed to unsubscribe from '{subscription_id}.'")

View file

@ -4,38 +4,24 @@
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
<q-form @submit="addRelay"> <q-form @submit="addRelay">
<div class="row q-pa-md"> <div class="row">
<div class="col-9"> <div class="col-12 col-md-7 q-pa-md">
<q-input <q-input outlined v-model="relayToAdd" dense filled label="Relay URL"></q-input>
outlined
v-model="relayToAdd"
dense
filled
label="Relay URL"
></q-input>
</div> </div>
<div class="col-3"> <div class="col-6 col-md-3 q-pa-md">
<q-btn-dropdown <q-btn-dropdown unelevated split color="primary" class="float-left" type="submit" label="Add Relay Y">
unelevated <q-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable
split v-close-popup>
color="primary"
class="float-right"
type="submit"
label="Add Relay X"
>
<q-item
v-for="relay in predefinedRelays"
:key="relay"
@click="addCustomRelay(relay)"
clickable
v-close-popup
>
<q-item-section> <q-item-section>
<q-item-label><span v-text="relay"></span></q-item-label> <q-item-label><span v-text="relay"></span></q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-btn-dropdown> </q-btn-dropdown>
</div> </div>
<div class="col-6 col-md-2 q-pa-md">
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings"
class="float-right"></q-btn>
</div>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
@ -46,36 +32,18 @@
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5> <h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<q-input <q-input borderless dense debounce="300" v-model="filter" placeholder="Search">
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append> <template v-slot:append>
<q-icon name="search"></q-icon> <q-icon name="search"></q-icon>
</template> </template>
</q-input> </q-input>
</div> </div>
</div> </div>
<q-table <q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns"
flat :pagination.sync="relayTable.pagination" :filter="filter">
dense
:data="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
:filter="filter"
>
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th <q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width>
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div> <div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div> <div v-else>{{ col.label }}</div>
</q-th> </q-th>
@ -84,12 +52,7 @@
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td <q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width>
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'connected'"> <div v-if="col.name == 'connected'">
<div v-if="col.value">🟢</div> <div v-if="col.value">🟢</div>
<div v-else>🔴</div> <div v-else>🔴</div>
@ -98,29 +61,17 @@
<div> <div>
⬆️ <span v-text="col.value.sentEvents"></span> ⬇️ ⬆️ <span v-text="col.value.sentEvents"></span> ⬇️
<span v-text="col.value.receveidEvents"></span> <span v-text="col.value.receveidEvents"></span>
<span <span @click="showLogDataDialog(col.value.errorList)" class="cursor-pointer">
@click="showLogDataDialog(col.value.errorList)"
class="cursor-pointer"
>
⚠️ <span v-text="col.value.errorCount"> </span> ⚠️ <span v-text="col.value.errorCount"> </span>
</span> </span>
<span <span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right">
@click="showLogDataDialog(col.value.noticeList)"
class="cursor-pointer float-right"
>
</span> </span>
</div> </div>
</div> </div>
<div v-else-if="col.name == 'delete'"> <div v-else-if="col.name == 'delete'">
<q-btn <q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel"
flat color="pink"></q-btn>
dense
size="md"
@click="showDeleteRelayDialog(props.row.url)"
icon="cancel"
color="pink"
></q-btn>
</div> </div>
<div v-else> <div v-else>
<div>{{ col.value }}</div> <div>{{ col.value }}</div>
@ -136,32 +87,15 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="text-weight-bold"> <div class="text-weight-bold">
<q-btn <q-btn flat dense size="0.6rem" class="q-px-none q-mx-none" color="grey" icon="content_copy"
flat @click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"><q-tooltip>Copy address</q-tooltip></q-btn>
dense
size="0.6rem"
class="q-px-none q-mx-none"
color="grey"
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"
><q-tooltip>Copy address</q-tooltip></q-btn
>
Your endpoint: Your endpoint:
<q-badge <q-badge outline class="q-ml-sm text-subtitle2" :label="`wss://${host}/nostrclient/api/v1/relay`" />
outline
class="q-ml-sm text-subtitle2"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</div> </div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-expansion-item <q-expansion-item group="advanced" icon="settings" label="Test this endpoint" @click="toggleTestPanel">
group="advanced"
icon="settings"
label="Test this endpoint"
@click="toggleTestPanel"
>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section> <q-card-section>
<div class="row"> <div class="row">
@ -169,13 +103,8 @@
<span>Sender Private Key:</span> <span>Sender Private Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input <q-input outlined v-model="testData.senderPrivateKey" dense filled
outlined label="Private Key (optional)"></q-input>
v-model="testData.senderPrivateKey"
dense
filled
label="Private Key (optional)"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-sm q-mb-lg"> <div class="row q-mt-sm q-mb-lg">
@ -184,8 +113,7 @@
<q-badge color="yellow" text-color="black"> <q-badge color="yellow" text-color="black">
<span> <span>
No not use your real private key! Leave empty for a randomly No not use your real private key! Leave empty for a randomly
generated key.</span generated key.</span>
>
</q-badge> </q-badge>
</div> </div>
</div> </div>
@ -194,13 +122,7 @@
<span>Sender Public Key:</span> <span>Sender Public Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input <q-input outlined v-model="testData.senderPublicKey" dense readonly filled></q-input>
outlined
v-model="testData.senderPublicKey"
dense
readonly
filled
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -208,15 +130,8 @@
<span>Test Message:</span> <span>Test Message:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input <q-input outlined v-model="testData.message" dense filled rows="3" type="textarea"
outlined label="Test Message *"></q-input>
v-model="testData.message"
dense
filled
rows="3"
type="textarea"
label="Test Message *"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -224,35 +139,22 @@
<span>Receiver Public Key:</span> <span>Receiver Public Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input <q-input outlined v-model="testData.recieverPublicKey" dense filled
outlined label="Public Key (hex or npub) *"></q-input>
v-model="testData.recieverPublicKey"
dense
filled
label="Public Key (hex or npub) *"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-sm q-mb-lg"> <div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div> <div class="col-3"></div>
<div class="col-9"> <div class="col-9">
<q-badge color="yellow" text-color="black"> <q-badge color="yellow" text-color="black">
<span <span>This is the recipient of the message. Field required.</span>
>This is the recipient of the message. Field required.</span
>
</q-badge> </q-badge>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<q-btn <q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated
:disabled="!testData.recieverPublicKey || !testData.message" color="primary" class="float-right">Send Message</q-btn>
@click="sendTestMessage"
unelevated
color="primary"
class="float-right"
>Send Message</q-btn
>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -264,14 +166,7 @@
<span>Sent Data:</span> <span>Sent Data:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input <q-input outlined v-model="testData.sentData" dense filled rows="5" type="textarea"></q-input>
outlined
v-model="testData.sentData"
dense
filled
rows="5"
type="textarea"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -279,14 +174,7 @@
<span>Received Data:</span> <span>Received Data:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input <q-input outlined v-model="testData.receivedData" dense filled rows="5" type="textarea"></q-input>
outlined
v-model="testData.receivedData"
dense
filled
rows="5"
type="textarea"
></q-input>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -305,12 +193,8 @@
</p> </p>
<p> <p>
<q-badge <q-badge outline class="q-ml-sm text-subtitle2" color="primary"
outline :label="`wss://${host}/nostrclient/api/v1/relay`" />
class="q-ml-sm text-subtitle2"
color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</p> </p>
Only Admin users can manage this extension. Only Admin users can manage this extension.
<q-card-section></q-card-section> <q-card-section></q-card-section>
@ -320,21 +204,27 @@
<q-dialog v-model="logData.show" position="top"> <q-dialog v-model="logData.show" position="top">
<q-card class="q-pa-lg q-pt-xl"> <q-card class="q-pa-lg q-pt-xl">
<q-input <q-input filled dense v-model.trim="logData.data" type="textarea" rows="25" cols="200" label="Log Data"></q-input>
filled
dense
v-model.trim="logData.data"
type="textarea"
rows="25"
cols="200"
label="Log Data"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="config.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="updateConfig" class="q-gutter-md">
<q-toggle label="Expose Private Websocket" color="secodary" v-model="config.data.private_ws"></q-toggle>
<br />
<q-toggle label="Expose Public Websocket" color="secodary" v-model="config.data.public_ws"></q-toggle>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div> </div>
{% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }} {% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
@ -380,6 +270,10 @@
show: false, show: false,
data: null data: null
}, },
config: {
showDialog: false,
data: {},
},
testData: { testData: {
show: false, show: false,
wsConnection: null, wsConnection: null,
@ -477,7 +371,7 @@
'POST', 'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id, '/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{url: this.relayToAdd} { url: this.relayToAdd }
) )
.then(function (response) { .then(function (response) {
console.log('response:', response) console.log('response:', response)
@ -509,7 +403,7 @@
'DELETE', 'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id, '/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{url: url} { url: url }
) )
.then(response => { .then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url) const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
@ -522,6 +416,34 @@
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
getConfig: async function () {
try {
const { data } = await LNbits.api
.request(
'GET',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateConfig: async function () {
try {
const { data } = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey,
this.config.data
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.config.showDialog = false
},
toggleTestPanel: async function () { toggleTestPanel: async function () {
if (this.testData.show) { if (this.testData.show) {
await this.hideTestPannel() await this.hideTestPannel()
@ -559,7 +481,7 @@
}, },
sendTestMessage: async function () { sendTestMessage: async function () {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id, '/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
@ -580,7 +502,7 @@
const subscription = JSON.stringify([ const subscription = JSON.stringify([
'REQ', 'REQ',
'test-dms', 'test-dms',
{kinds: [4], '#p': [event.pubkey]} { kinds: [4], '#p': [event.pubkey] }
]) ])
this.testData.wsConnection.send(subscription) this.testData.wsConnection.send(subscription)
} catch (error) { } catch (error) {
@ -642,9 +564,9 @@
}, },
sleep: ms => new Promise(r => setTimeout(r, ms)) sleep: ms => new Promise(r => setTimeout(r, ms))
}, },
created: function () { created: async function () {
var self = this
this.getRelays() this.getRelays()
await this.getConfig()
setInterval(this.getRelays, 5000) setInterval(this.getRelays, 5000)
} }
}) })

View file

@ -7,12 +7,12 @@ from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.decorators import check_admin from lnbits.decorators import check_admin
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash
from . import nostr_client, nostrclient_ext, scheduled_tasks from . import nostr_client, nostrclient_ext, scheduled_tasks
from .crud import add_relay, delete_relay, get_relays from .crud import add_relay, create_config, delete_relay, get_config, get_relays, update_config
from .helpers import normalize_public_key from .helpers import normalize_public_key
from .models import Relay, TestMessage, TestMessageResponse from .models import Config, Relay, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey from .nostr.key import EncryptedDirectMessage, PrivateKey
from .router import NostrRouter from .router import NostrRouter
@ -20,7 +20,7 @@ from .router import NostrRouter
all_routers: list[NostrRouter] = [] all_routers: list[NostrRouter] = []
@nostrclient_ext.get("/api/v1/relays") @nostrclient_ext.get("/api/v1/relays", dependencies=[Depends(check_admin)])
async def api_get_relays() -> List[Relay]: async def api_get_relays() -> List[Relay]:
relays = [] relays = []
for url, r in nostr_client.relay_manager.relays.items(): for url, r in nostr_client.relay_manager.relays.items():
@ -133,19 +133,68 @@ async def api_stop():
return {"success": True} return {"success": True}
@nostrclient_ext.websocket("/api/v1/relay") @nostrclient_ext.websocket("/api/v1/{id}")
async def ws_relay(websocket: WebSocket) -> None: async def ws_relay(id: str, websocket: WebSocket) -> None:
"""Relay multiplexer: one client (per endpoint) <-> multiple relays""" """Relay multiplexer: one client (per endpoint) <-> multiple relays"""
await websocket.accept()
router = NostrRouter(websocket)
router.start()
all_routers.append(router)
# we kill this websocket and the subscriptions logger.info("New websocket connection at: '/api/v1/relay'")
# if the user disconnects and thus `connected==False` try:
while router.connected: config = await get_config()
await asyncio.sleep(10)
await router.stop() if not config.private_ws and not config.public_ws:
all_routers.remove(router) raise ValueError("Websocket connections not accepted.")
if id == "relay":
if not config.public_ws:
raise ValueError("Public websocket connections not accepted.")
else:
if not config.private_ws:
raise ValueError("Private websocket connections not accepted.")
if decrypt_internal_message(id) != "relay":
raise ValueError("Invalid websocket endpoint.")
await websocket.accept()
router = NostrRouter(websocket)
router.start()
all_routers.append(router)
# we kill this websocket and the subscriptions
# if the user disconnects and thus `connected==False`
while router.connected:
await asyncio.sleep(10)
try:
await router.stop()
except Exception as e:
logger.debug(e)
all_routers.remove(router)
logger.info("Closed websocket connection at: '/api/v1/relay'")
except ValueError as ex:
logger.warning(ex)
await websocket.close(reason=str(ex))
except Exception as ex:
logger.warning(ex)
await websocket.close(reason="Websocket connection unexpected closed")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot accept websocket connection",
)
@nostrclient_ext.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_relays() -> Config:
config = await get_config()
if not config:
await create_config()
return config
@nostrclient_ext.put("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_update_config(
data: Config
):
config = await update_config(data)
assert config
return config.dict()