feat: update to v1.0.0 (#30)

This commit is contained in:
dni ⚡ 2024-11-08 14:32:04 +01:00 committed by GitHub
parent 2bdbbb274d
commit 73054fd5ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2029 additions and 2132 deletions

View file

@ -2,12 +2,17 @@
"name": "Nostr Relay", "name": "Nostr Relay",
"short_description": "One click launch your own relay!", "short_description": "One click launch your own relay!",
"tile": "/nostrrelay/static/image/nostrrelay.png", "tile": "/nostrrelay/static/image/nostrrelay.png",
"min_lnbits_version": "0.12.6", "min_lnbits_version": "1.0.0",
"contributors": [ "contributors": [
{ {
"name": "motorina0", "name": "motorina0",
"uri": "https://github.com/motorina0", "uri": "https://github.com/motorina0",
"role": "Contributor" "role": "Contributor"
},
{
"name": "dni",
"uri": "https://github.com/dni",
"role": "Contributor"
} }
], ],
"images": [ "images": [

367
crud.py
View file

@ -1,115 +1,72 @@
import json import json
from typing import List, Optional, Tuple from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from .models import NostrAccount from .models import NostrAccount, NostrEventTags
from .relay.event import NostrEvent from .relay.event import NostrEvent
from .relay.filter import NostrFilter from .relay.filter import NostrFilter
from .relay.relay import NostrRelay, RelayPublicSpec, RelaySpec from .relay.relay import NostrRelay, RelayPublicSpec
db = Database("ext_nostrrelay") db = Database("ext_nostrrelay")
########################## RELAYS ####################
async def create_relay(relay: NostrRelay) -> NostrRelay:
async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay: await db.insert("nostrrelay.relays", relay)
await db.execute(
"""
INSERT INTO nostrrelay.relays
(user_id, id, name, description, pubkey, contact, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
r.id,
r.name,
r.description,
r.pubkey,
r.contact,
json.dumps(dict(r.config)),
),
)
relay = await get_relay(user_id, r.id)
assert relay, "Created relay cannot be retrieved"
return relay return relay
async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay: async def update_relay(relay: NostrRelay) -> NostrRelay:
await db.execute( await db.update("nostrrelay.relays", relay, "WHERE user_id = :user_id AND id = :id")
""" return relay
UPDATE nostrrelay.relays
SET (name, description, pubkey, contact, active, meta) = (?, ?, ?, ?, ?, ?)
WHERE user_id = ? AND id = ?
""",
(
r.name,
r.description,
r.pubkey,
r.contact,
r.active,
json.dumps(dict(r.config)),
user_id,
r.id,
),
)
return r
async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]: async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]:
row = await db.fetchone( return await db.fetchone(
"""SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", "SELECT * FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
( {"user_id": user_id, "id": relay_id},
user_id, NostrRelay,
relay_id,
),
) )
return NostrRelay.from_row(row) if row else None
async def get_relay_by_id(relay_id: str) -> Optional[NostrRelay]: async def get_relay_by_id(relay_id: str) -> Optional[NostrRelay]:
"""Note: it does not require `user_id`. Can read any relay. Use it with care.""" """Note: it does not require `user_id`. Can read any relay. Use it with care."""
row = await db.fetchone( return await db.fetchone(
"""SELECT * FROM nostrrelay.relays WHERE id = ?""", "SELECT * FROM nostrrelay.relays WHERE id = :id",
(relay_id,), {"id": relay_id},
NostrRelay,
) )
return NostrRelay.from_row(row) if row else None
async def get_relays(user_id: str) -> list[NostrRelay]:
async def get_relays(user_id: str) -> List[NostrRelay]: return await db.fetchall(
rows = await db.fetchall( "SELECT * FROM nostrrelay.relays WHERE user_id = :user_id ORDER BY id ASC",
"""SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""", {"user_id": user_id},
(user_id,), NostrRelay,
) )
return [NostrRelay.from_row(row) for row in rows]
async def get_config_for_all_active_relays() -> dict: async def get_config_for_all_active_relays() -> dict:
rows = await db.fetchall( relays = await db.fetchall(
"SELECT id, meta FROM nostrrelay.relays WHERE active = true", "SELECT id, meta FROM nostrrelay.relays WHERE active = true",
model=NostrRelay,
) )
active_relay_configs = {} active_relay_configs = {}
for r in rows: for relay in relays:
active_relay_configs[r["id"]] = RelaySpec( active_relay_configs[relay.id] = relay.meta.dict()
**json.loads(r["meta"])
) # todo: from_json
return active_relay_configs return active_relay_configs
async def get_public_relay(relay_id: str) -> Optional[dict]: async def get_public_relay(relay_id: str) -> Optional[dict]:
row = await db.fetchone( relay = await db.fetchone(
"""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,) "SELECT * FROM nostrrelay.relays WHERE id = :id",
{"id": relay_id},
NostrRelay,
) )
if not relay:
if not row:
return None return None
relay = NostrRelay.from_row(row)
return { return {
**NostrRelay.info(), **NostrRelay.info(),
"id": relay.id, "id": relay.id,
@ -117,88 +74,66 @@ async def get_public_relay(relay_id: str) -> Optional[dict]:
"description": relay.description, "description": relay.description,
"pubkey": relay.pubkey, "pubkey": relay.pubkey,
"contact": relay.contact, "contact": relay.contact,
"config": RelayPublicSpec(**dict(relay.config)).dict(by_alias=True), "config": RelayPublicSpec(**relay.meta.dict()).dict(by_alias=True),
} }
async def delete_relay(user_id: str, relay_id: str): async def delete_relay(user_id: str, relay_id: str):
await db.execute( await db.execute(
"""DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", "DELETE FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
( {"user_id": user_id, "id": relay_id},
user_id,
relay_id,
),
) )
########################## EVENTS #################### async def create_event(event: NostrEvent):
async def create_event(relay_id: str, e: NostrEvent, publisher: Optional[str]): await db.update("nostrrelay.events", event)
publisher = publisher if publisher else e.pubkey
await db.execute(
"""
INSERT INTO nostrrelay.events (
relay_id,
publisher,
id,
pubkey,
created_at,
kind,
content,
sig,
size
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (relay_id, id) DO NOTHING
""",
(
relay_id,
publisher,
e.id,
e.pubkey,
e.created_at,
e.kind,
e.content,
e.sig,
e.size_bytes,
),
)
# todo: optimize with bulk insert # todo: optimize with bulk insert
for tag in e.tags: for tag in event.tags:
name, value, *rest = tag name, value, *rest = tag
extra = json.dumps(rest) if rest else None extra = json.dumps(rest) if rest else None
await create_event_tags(relay_id, e.id, name, value, extra) _tag = NostrEventTags(
event_id=event.id,
name=name,
value=value,
extra=extra,
)
await create_event_tags(_tag)
async def get_events( async def get_events(
relay_id: str, nostr_filter: NostrFilter, include_tags=True relay_id: str, nostr_filter: NostrFilter, include_tags=True
) -> List[NostrEvent]: ) -> list[NostrEvent]:
query, values = build_select_events_query(relay_id, nostr_filter)
rows = await db.fetchall(query, tuple(values)) inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
query = f"""
SELECT * FROM nostrrelay.events
{" ".join(inner_joins)}
WHERE { " AND ".join(where)}
ORDER BY created_at DESC
"""
events = [] # todo: check & enforce range
for row in rows: if nostr_filter.limit and nostr_filter.limit > 0:
event = NostrEvent.from_row(row) query += f" LIMIT {nostr_filter.limit}"
events = await db.fetchall(query, values, NostrEvent)
for event in events:
if include_tags: if include_tags:
event.tags = await get_event_tags(relay_id, event.id) event.tags = await get_event_tags(relay_id, event.id)
events.append(event)
return events return events
async def get_event(relay_id: str, event_id: str) -> Optional[NostrEvent]: async def get_event(relay_id: str, event_id: str) -> Optional[NostrEvent]:
row = await db.fetchone( event = await db.fetchone(
"SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", "SELECT * FROM nostrrelay.events WHERE relay_id = :relay_id AND id = :id",
( {"relay_id": relay_id, "id": event_id},
relay_id, NostrEvent,
event_id,
),
) )
if not row: if not event:
return None return None
event = NostrEvent.from_row(row)
event.tags = await get_event_tags(relay_id, event_id) event.tags = await get_event_tags(relay_id, event_id)
return event return event
@ -209,36 +144,36 @@ async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> in
Deleted events are also counted Deleted events are also counted
""" """
row = await db.fetchone( result = await db.execute(
""" """
SELECT SUM(size) as sum FROM nostrrelay.events SELECT SUM(size) as sum FROM nostrrelay.events
WHERE relay_id = ? AND publisher = ? GROUP BY publisher WHERE relay_id = :relay_id AND publisher = :publisher GROUP BY publisher
""", """,
( {"relay_id": relay_id, "publisher": publisher_pubkey},
relay_id,
publisher_pubkey,
),
) )
row = await result.mappings().first()
if not row: if not row:
return 0 return 0
return round(row["sum"]) return round(row["sum"])
async def get_prunable_events(relay_id: str, pubkey: str) -> List[Tuple[str, int]]: async def get_prunable_events(relay_id: str, pubkey: str) -> list[tuple[str, int]]:
""" """
Return the oldest 10 000 events. Only the `id` and the size are returned, Return the oldest 10 000 events. Only the `id` and the size are returned,
so the data size should be small so the data size should be small
""" """
query = """ events = await db.fetchall(
SELECT id, size FROM nostrrelay.events
WHERE relay_id = ? AND pubkey = ?
ORDER BY created_at ASC LIMIT 10000
""" """
SELECT * FROM nostrrelay.events
WHERE relay_id = :relay_id AND pubkey = :pubkey
ORDER BY created_at ASC LIMIT 10000
""",
{"relay_id": relay_id, "pubkey": pubkey},
NostrEvent,
)
rows = await db.fetchall(query, (relay_id, pubkey)) return [(event.id, event.size_bytes) for event in events]
return [(r["id"], r["size"]) for r in rows]
async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter): async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter):
@ -247,8 +182,8 @@ async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter):
_, where, values = nostr_filter.to_sql_components(relay_id) _, where, values = nostr_filter.to_sql_components(relay_id)
await db.execute( await db.execute(
f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", f"UPDATE nostrrelay.events SET deleted=true WHERE {' AND '.join(where)}",
tuple(values), values,
) )
@ -257,11 +192,12 @@ async def delete_events(relay_id: str, nostr_filter: NostrFilter):
return None return None
_, where, values = nostr_filter.to_sql_components(relay_id) _, where, values = nostr_filter.to_sql_components(relay_id)
query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}""" query = f"DELETE from nostrrelay.events WHERE {' AND '.join(where)}"
await db.execute(query, tuple(values)) await db.execute(query, values)
# todo: delete tags # todo: delete tags
# move to services
async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int): async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
prunable_events = await get_prunable_events(relay_id, pubkey) prunable_events = await get_prunable_events(relay_id, pubkey)
prunable_event_ids = [] prunable_event_ids = []
@ -278,113 +214,58 @@ async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
async def delete_all_events(relay_id: str): async def delete_all_events(relay_id: str):
query = "DELETE from nostrrelay.events WHERE relay_id = ?" await db.execute(
await db.execute(query, (relay_id,)) "DELETE from nostrrelay.events WHERE relay_id = :id",
{"id": relay_id},
)
# todo: delete tags # todo: delete tags
async def create_event_tags( async def create_event_tags(tag: NostrEventTags):
relay_id: str, await db.insert("nostrrelay.event_tags", tag)
event_id: str,
tag_name: str,
tag_value: str, async def get_event_tags(relay_id: str, event_id: str) -> list[list[str]]:
extra_values: Optional[str], _tags = await db.fetchall(
):
await db.execute(
""" """
INSERT INTO nostrrelay.event_tags ( SELECT * FROM nostrrelay.event_tags
relay_id, WHERE relay_id = :relay_id and event_id = :event_id
event_id,
name,
value,
extra
)
VALUES (?, ?, ?, ?, ?)
""", """,
(relay_id, event_id, tag_name, tag_value, extra_values), {"relay_id": relay_id, "event_id": event_id},
model=NostrEventTags,
) )
tags: list[list[str]] = []
async def get_event_tags(relay_id: str, event_id: str) -> List[List[str]]: for tag in _tags:
rows = await db.fetchall( _tag = [tag.name, tag.value]
"SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?", if tag.extra:
(relay_id, event_id), _tag += json.loads(tag.extra)
) tags.append(_tag)
tags: List[List[str]] = []
for row in rows:
tag = [row["name"], row["value"]]
extra = row["extra"]
if extra:
tag += json.loads(extra)
tags.append(tag)
return tags return tags
def build_select_events_query(relay_id: str, nostr_filter: NostrFilter): async def create_account(account: NostrAccount) -> NostrAccount:
inner_joins, where, values = nostr_filter.to_sql_components(relay_id) await db.insert("nostrrelay.accounts", account)
query = f"""
SELECT id, pubkey, created_at, kind, content, sig
FROM nostrrelay.events
{" ".join(inner_joins)}
WHERE { " AND ".join(where)}
ORDER BY created_at DESC
"""
# todo: check & enforce range
if nostr_filter.limit and nostr_filter.limit > 0:
query += f" LIMIT {nostr_filter.limit}"
return query, values
########################## ACCOUNTS ####################
async def create_account(relay_id: str, a: NostrAccount) -> NostrAccount:
await db.execute(
"""
INSERT INTO nostrrelay.accounts
(relay_id, pubkey, sats, storage, paid_to_join, allowed, blocked)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
relay_id,
a.pubkey,
a.sats,
a.storage,
a.paid_to_join,
a.allowed,
a.blocked,
),
)
account = await get_account(relay_id, a.pubkey)
assert account, "Created account cannot be retrieved"
return account return account
async def update_account(relay_id: str, a: NostrAccount) -> NostrAccount: async def update_account(account: NostrAccount) -> NostrAccount:
await db.execute( await db.update(
""" "nostrrelay.accounts",
UPDATE nostrrelay.accounts account,
SET (sats, storage, paid_to_join, allowed, blocked) = (?, ?, ?, ?, ?) "WHERE relay_id = :relay_id AND pubkey = :pubkey",
WHERE relay_id = ? AND pubkey = ?
""",
(a.sats, a.storage, a.paid_to_join, a.allowed, a.blocked, relay_id, a.pubkey),
) )
return account
return a
async def delete_account(relay_id: str, pubkey: str): async def delete_account(relay_id: str, pubkey: str):
await db.execute( await db.execute(
""" """
DELETE FROM nostrrelay.accounts DELETE FROM nostrrelay.accounts
WHERE relay_id = ? AND pubkey = ? WHERE relay_id = :id AND pubkey = :pubkey
""", """,
(relay_id, pubkey), {"id": relay_id, "pubkey": pubkey},
) )
@ -392,28 +273,28 @@ async def get_account(
relay_id: str, relay_id: str,
pubkey: str, pubkey: str,
) -> Optional[NostrAccount]: ) -> Optional[NostrAccount]:
row = await db.fetchone( return await db.fetchone(
"SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND pubkey = ?", """
(relay_id, pubkey), SELECT * FROM nostrrelay.accounts
WHERE relay_id = :id AND pubkey = :pubkey
""",
{"id": relay_id, "pubkey": pubkey},
NostrAccount,
) )
return NostrAccount.from_row(row) if row else None
async def get_accounts( async def get_accounts(
relay_id: str, relay_id: str,
allowed=True, allowed=True,
blocked=False, blocked=False,
) -> List[NostrAccount]: ) -> list[NostrAccount]:
if not allowed and not blocked: if not allowed and not blocked:
return [] return []
return await db.fetchall(
rows = await db.fetchall(
""" """
SELECT * FROM nostrrelay.accounts SELECT * FROM nostrrelay.accounts
WHERE relay_id = ? AND allowed = ? OR blocked = ?" WHERE relay_id = :id AND allowed = :allowed OR blocked = :blocked
""", """,
(relay_id, allowed, blocked), {"id": relay_id, "allowed": allowed, "blocked": blocked},
NostrAccount,
) )
return [NostrAccount.from_row(row) for row in rows]

View file

@ -1,4 +1,3 @@
from sqlite3 import Row
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
@ -23,6 +22,7 @@ class NostrPartialAccount(BaseModel):
class NostrAccount(BaseModel): class NostrAccount(BaseModel):
pubkey: str pubkey: str
relay_id: str
sats: int = 0 sats: int = 0
storage: int = 0 storage: int = 0
paid_to_join: bool = False paid_to_join: bool = False
@ -36,8 +36,11 @@ class NostrAccount(BaseModel):
@classmethod @classmethod
def null_account(cls) -> "NostrAccount": def null_account(cls) -> "NostrAccount":
return NostrAccount(pubkey="") return NostrAccount(pubkey="", relay_id="")
@classmethod
def from_row(cls, row: Row) -> "NostrAccount": class NostrEventTags(BaseModel):
return cls(**dict(row)) event_id: str
name: str
value: str
extra: Optional[str] = None

2270
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ authors = ["dni <dni@lnbits.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10 | ^3.9" python = "^3.10 | ^3.9"
lnbits = "*" lnbits = {allow-prereleases = true, version = "*"}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^24.3.0" black = "^24.3.0"

View file

@ -154,7 +154,7 @@ class NostrClientConnection:
NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at), NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at),
) )
if not e.is_ephemeral_event: if not e.is_ephemeral_event:
await create_event(self.relay_id, e, self.auth_pubkey) await create_event(e)
await self._broadcast_event(e) await self._broadcast_event(e)
if e.is_delete_event: if e.is_delete_event:

View file

@ -1,8 +1,6 @@
import hashlib import hashlib
import json import json
from enum import Enum from enum import Enum
from sqlite3 import Row
from typing import List
from pydantic import BaseModel from pydantic import BaseModel
from secp256k1 import PublicKey from secp256k1 import PublicKey
@ -20,11 +18,11 @@ class NostrEvent(BaseModel):
pubkey: str pubkey: str
created_at: int created_at: int
kind: int kind: int
tags: List[List[str]] = [] tags: list[list[str]] = []
content: str = "" content: str = ""
sig: str sig: str
def serialize(self) -> List: def serialize(self) -> list:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str: def serialize_json(self) -> str:
@ -87,7 +85,7 @@ class NostrEvent(BaseModel):
def serialize_response(self, subscription_id): def serialize_response(self, subscription_id):
return [NostrEventType.EVENT, subscription_id, dict(self)] return [NostrEventType.EVENT, subscription_id, dict(self)]
def tag_values(self, tag_name: str) -> List[str]: def tag_values(self, tag_name: str) -> list[str]:
return [t[1] for t in self.tags if t[0] == tag_name] return [t[1] for t in self.tags if t[0] == tag_name]
def has_tag_value(self, tag_name: str, tag_value: str) -> bool: def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
@ -95,7 +93,3 @@ class NostrEvent(BaseModel):
def is_direct_message_for_pubkey(self, pubkey: str) -> bool: def is_direct_message_for_pubkey(self, pubkey: str) -> bool:
return self.is_direct_message and self.has_tag_value("p", pubkey) return self.is_direct_message and self.has_tag_value("p", pubkey)
@classmethod
def from_row(cls, row: Row) -> "NostrEvent":
return cls(**dict(row))

View file

@ -1,4 +1,4 @@
from typing import Any, List, Optional, Tuple from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -6,11 +6,11 @@ from .event import NostrEvent
class NostrFilter(BaseModel): class NostrFilter(BaseModel):
e: List[str] = Field(default=[], alias="#e") e: list[str] = Field(default=[], alias="#e")
p: List[str] = Field(default=[], alias="#p") p: list[str] = Field(default=[], alias="#p")
ids: List[str] = [] ids: list[str] = []
authors: List[str] = [] authors: list[str] = []
kinds: List[int] = [] kinds: list[int] = []
subscription_id: Optional[str] = None subscription_id: Optional[str] = None
since: Optional[int] = None since: Optional[int] = None
until: Optional[int] = None until: Optional[int] = None
@ -66,16 +66,13 @@ class NostrFilter(BaseModel):
if not self.limit or self.limit > limit: if not self.limit or self.limit > limit:
self.limit = limit self.limit = limit
def to_sql_components( def to_sql_components(self, relay_id: str) -> tuple[list[str], list[str], dict]:
self, relay_id: str inner_joins: list[str] = []
) -> Tuple[List[str], List[str], List[Any]]: where = ["deleted=false", "nostrrelay.events.relay_id = :relay_id"]
inner_joins: List[str] = [] values: dict = {"relay_id": relay_id}
where = ["deleted=false", "nostrrelay.events.relay_id = ?"]
values: List[Any] = [relay_id]
if len(self.e): if len(self.e):
values += self.e e_s = ",".join([f"'{e}'" for e in self.e])
e_s = ",".join(["?"] * len(self.e))
inner_joins.append( inner_joins.append(
"INNER JOIN nostrrelay.event_tags e_tags " "INNER JOIN nostrrelay.event_tags e_tags "
"ON nostrrelay.events.id = e_tags.event_id" "ON nostrrelay.events.id = e_tags.event_id"
@ -83,8 +80,7 @@ class NostrFilter(BaseModel):
where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')") where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')")
if len(self.p): if len(self.p):
values += self.p p_s = ",".join([f"'{p}'" for p in self.p])
p_s = ",".join(["?"] * len(self.p))
inner_joins.append( inner_joins.append(
"INNER JOIN nostrrelay.event_tags p_tags " "INNER JOIN nostrrelay.event_tags p_tags "
"ON nostrrelay.events.id = p_tags.event_id" "ON nostrrelay.events.id = p_tags.event_id"
@ -92,26 +88,23 @@ class NostrFilter(BaseModel):
where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'") where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'")
if len(self.ids) != 0: if len(self.ids) != 0:
ids = ",".join(["?"] * len(self.ids)) ids = ",".join([f"'{_id}'" for _id in self.ids])
where.append(f"id IN ({ids})") where.append(f"id IN ({ids})")
values += self.ids
if len(self.authors) != 0: if len(self.authors) != 0:
authors = ",".join(["?"] * len(self.authors)) authors = ",".join([f"'{author}'" for author in self.authors])
where.append(f"pubkey IN ({authors})") where.append(f"pubkey IN ({authors})")
values += self.authors
if len(self.kinds) != 0: if len(self.kinds) != 0:
kinds = ",".join(["?"] * len(self.kinds)) kinds = ",".join([f"'{kind}'" for kind in self.kinds])
where.append(f"kind IN ({kinds})") where.append(f"kind IN ({kinds})")
values += self.kinds
if self.since: if self.since:
where.append("created_at >= ?") where.append("created_at >= :since")
values += [self.since] values["since"] = self.since
if self.until: if self.until:
where.append("created_at < ?") where.append("created_at < :until")
values += [self.until] values["until"] = self.until
return inner_joins, where, values return inner_joins, where, values

View file

@ -1,5 +1,3 @@
import json
from sqlite3 import Row
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -102,23 +100,17 @@ class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
class NostrRelay(BaseModel): class NostrRelay(BaseModel):
id: str id: str
user_id: Optional[str] = None
name: str name: str
description: Optional[str] description: Optional[str] = None
pubkey: Optional[str] pubkey: Optional[str] = None
contact: Optional[str] contact: Optional[str] = None
active: bool = False active: bool = False
meta: RelaySpec = RelaySpec()
config = RelaySpec()
@property @property
def is_free_to_join(self): def is_free_to_join(self):
return not self.config.is_paid_relay or self.config.cost_to_join == 0 return not self.meta.is_paid_relay or self.meta.cost_to_join == 0
@classmethod
def from_row(cls, row: Row) -> "NostrRelay":
relay = cls(**dict(row))
relay.config = RelaySpec(**json.loads(row["meta"]))
return relay
@classmethod @classmethod
def info( def info(

View file

@ -0,0 +1,290 @@
window.app.component('relay-details', {
name: 'relay-details',
template: '#relay-details',
props: ['relay-id', 'adminkey', 'inkey', 'wallet-options'],
data() {
return {
tab: 'info',
relay: null,
accounts: [],
accountPubkey: '',
formDialogItem: {
show: false,
data: {
name: '',
description: ''
}
},
showBlockedAccounts: true,
showAllowedAccounts: false,
accountsTable: {
columns: [
{
name: 'action',
align: 'left',
label: '',
field: ''
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
},
{
name: 'allowed',
align: 'left',
label: 'Allowed',
field: 'allowed'
},
{
name: 'blocked',
align: 'left',
label: 'Blocked',
field: 'blocked'
},
{
name: 'paid_to_join',
align: 'left',
label: 'Paid to join',
field: 'paid_to_join'
},
{
name: 'sats',
align: 'left',
label: 'Spent Sats',
field: 'sats'
},
{
name: 'storage',
align: 'left',
label: 'Storage',
field: 'storage'
}
],
pagination: {
rowsPerPage: 10
}
},
skipEventKind: 0,
forceEventKind: 0
}
},
computed: {
hours() {
const y = []
for (let i = 0; i <= 24; i++) {
y.push(i)
}
return y
},
range60() {
const y = []
for (let i = 0; i <= 60; i++) {
y.push(i)
}
return y
},
storageUnits() {
return ['KB', 'MB']
},
fullStorageActions() {
return [
{value: 'block', label: 'Block New Events'},
{value: 'prune', label: 'Prune Old Events'}
]
},
wssLink() {
this.relay.meta.domain =
this.relay.meta.domain || window.location.hostname
return 'wss://' + this.relay.meta.domain + '/nostrrelay/' + this.relay.id
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
deleteRelay() {
LNbits.utils
.confirmDialog(
'All data will be lost! Are you sure you want to delete this relay?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.adminkey
)
this.$emit('relay-deleted', this.relayId)
Quasar.Notify.create({
type: 'positive',
message: 'Relay Deleted',
timeout: 5000
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
async getRelay() {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.inkey
)
this.relay = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async updateRelay() {
try {
const {data} = await LNbits.api.request(
'PATCH',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.adminkey,
this.relay
)
this.relay = data
this.$emit('relay-updated', this.relay)
this.$q.notify({
type: 'positive',
message: 'Relay Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
togglePaidRelay: async function () {
this.relay.meta.wallet =
this.relay.meta.wallet || this.walletOptions[0].value
},
getAccounts: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrrelay/api/v1/account?relay_id=${this.relay.id}&allowed=${this.showAllowedAccounts}&blocked=${this.showBlockedAccounts}`,
this.inkey
)
this.accounts = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
allowPublicKey: async function (pubkey, allowed) {
await this.updatePublicKey({pubkey, allowed})
},
blockPublicKey: async function (pubkey, blocked = true) {
await this.updatePublicKey({pubkey, blocked})
},
removePublicKey: async function (pubkey) {
LNbits.utils
.confirmDialog('This public key will be removed from relay!')
.onOk(async () => {
await this.deletePublicKey(pubkey)
})
},
togglePublicKey: async function (account, action) {
if (action === 'allow') {
await this.updatePublicKey({
pubkey: account.pubkey,
allowed: account.allowed
})
}
if (action === 'block') {
await this.updatePublicKey({
pubkey: account.pubkey,
blocked: account.blocked
})
}
},
updatePublicKey: async function (ops) {
try {
await LNbits.api.request(
'PUT',
'/nostrrelay/api/v1/account',
this.adminkey,
{
relay_id: this.relay.id,
pubkey: ops.pubkey,
allowed: ops.allowed,
blocked: ops.blocked
}
)
this.$q.notify({
type: 'positive',
message: 'Account Updated',
timeout: 5000
})
this.accountPubkey = ''
await this.getAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deletePublicKey: async function (pubkey) {
try {
await LNbits.api.request(
'DELETE',
`/nostrrelay/api/v1/account/${this.relay.id}/${pubkey}`,
this.adminkey,
{}
)
this.$q.notify({
type: 'positive',
message: 'Account Deleted',
timeout: 5000
})
await this.getAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
addSkipAuthForEvent: function () {
value = +this.skipEventKind
if (this.relay.meta.skipedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.meta.skipedAuthEvents.push(value)
},
removeSkipAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.meta.skipedAuthEvents =
this.relay.meta.skipedAuthEvents.filter(e => e !== value)
},
addForceAuthForEvent: function () {
value = +this.forceEventKind
if (this.relay.meta.forcedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.meta.forcedAuthEvents.push(value)
},
removeForceAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.meta.forcedAuthEvents =
this.relay.meta.forcedAuthEvents.filter(e => e !== value)
},
// todo: bad. base.js not present in custom components
copyText: function (text, message, position) {
Quasar.copyToClipboard(text).then(function () {
Quasar.Notify.create({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
}
},
async created() {
await this.getRelay()
await this.getAccounts()
}
})

View file

@ -1,298 +0,0 @@
async function relayDetails(path) {
const template = await loadTemplateAsync(path)
Vue.component('relay-details', {
name: 'relay-details',
template,
props: ['relay-id', 'adminkey', 'inkey', 'wallet-options'],
data: function () {
return {
tab: 'info',
relay: null,
accounts: [],
accountPubkey: '',
formDialogItem: {
show: false,
data: {
name: '',
description: ''
}
},
showBlockedAccounts: true,
showAllowedAccounts: false,
accountsTable: {
columns: [
{
name: 'action',
align: 'left',
label: '',
field: ''
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
},
{
name: 'allowed',
align: 'left',
label: 'Allowed',
field: 'allowed'
},
{
name: 'blocked',
align: 'left',
label: 'Blocked',
field: 'blocked'
},
{
name: 'paid_to_join',
align: 'left',
label: 'Paid to join',
field: 'paid_to_join'
},
{
name: 'sats',
align: 'left',
label: 'Spent Sats',
field: 'sats'
},
{
name: 'storage',
align: 'left',
label: 'Storage',
field: 'storage'
}
],
pagination: {
rowsPerPage: 10
}
},
skipEventKind: 0,
forceEventKind: 0
}
},
computed: {
hours: function () {
const y = []
for (let i = 0; i <= 24; i++) {
y.push(i)
}
return y
},
range60: function () {
const y = []
for (let i = 0; i <= 60; i++) {
y.push(i)
}
return y
},
storageUnits: function () {
return ['KB', 'MB']
},
fullStorageActions: function () {
return [
{value: 'block', label: 'Block New Events'},
{value: 'prune', label: 'Prune Old Events'}
]
},
wssLink: function () {
this.relay.config.domain =
this.relay.config.domain || window.location.hostname
return (
'wss://' + this.relay.config.domain + '/nostrrelay/' + this.relay.id
)
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
deleteRelay: function () {
LNbits.utils
.confirmDialog(
'All data will be lost! Are you sure you want to delete this relay?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.adminkey
)
this.$emit('relay-deleted', this.relayId)
this.$q.notify({
type: 'positive',
message: 'Relay Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
getRelay: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.inkey
)
this.relay = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateRelay: async function () {
try {
const {data} = await LNbits.api.request(
'PATCH',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.adminkey,
this.relay
)
this.relay = data
this.$emit('relay-updated', this.relay)
this.$q.notify({
type: 'positive',
message: 'Relay Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
togglePaidRelay: async function () {
this.relay.config.wallet =
this.relay.config.wallet || this.walletOptions[0].value
},
getAccounts: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrrelay/api/v1/account?relay_id=${this.relay.id}&allowed=${this.showAllowedAccounts}&blocked=${this.showBlockedAccounts}`,
this.inkey
)
this.accounts = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
allowPublicKey: async function (pubkey, allowed) {
await this.updatePublicKey({pubkey, allowed})
},
blockPublicKey: async function (pubkey, blocked = true) {
await this.updatePublicKey({pubkey, blocked})
},
removePublicKey: async function (pubkey) {
LNbits.utils
.confirmDialog('This public key will be removed from relay!')
.onOk(async () => {
await this.deletePublicKey(pubkey)
})
},
togglePublicKey: async function (account, action) {
if (action === 'allow') {
await this.updatePublicKey({
pubkey: account.pubkey,
allowed: account.allowed
})
}
if (action === 'block') {
await this.updatePublicKey({
pubkey: account.pubkey,
blocked: account.blocked
})
}
},
updatePublicKey: async function (ops) {
try {
await LNbits.api.request(
'PUT',
'/nostrrelay/api/v1/account',
this.adminkey,
{
relay_id: this.relay.id,
pubkey: ops.pubkey,
allowed: ops.allowed,
blocked: ops.blocked
}
)
this.$q.notify({
type: 'positive',
message: 'Account Updated',
timeout: 5000
})
this.accountPubkey = ''
await this.getAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deletePublicKey: async function (pubkey) {
try {
await LNbits.api.request(
'DELETE',
`/nostrrelay/api/v1/account/${this.relay.id}/${pubkey}`,
this.adminkey,
{}
)
this.$q.notify({
type: 'positive',
message: 'Account Deleted',
timeout: 5000
})
await this.getAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
addSkipAuthForEvent: function () {
value = +this.skipEventKind
if (this.relay.config.skipedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.config.skipedAuthEvents.push(value)
},
removeSkipAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.config.skipedAuthEvents =
this.relay.config.skipedAuthEvents.filter(e => e !== value)
},
addForceAuthForEvent: function () {
value = +this.forceEventKind
if (this.relay.config.forcedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.config.forcedAuthEvents.push(value)
},
removeForceAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.config.forcedAuthEvents =
this.relay.config.forcedAuthEvents.filter(e => e !== value)
},
// todo: bad. base.js not present in custom components
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
notify({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
}
},
created: async function () {
await this.getRelay()
await this.getAccounts()
}
})
}

View file

@ -1,80 +1,13 @@
const relays = async () => { window.app = Vue.createApp({
Vue.component(VueQrcode.name, VueQrcode) el: '#vue',
mixins: [windowMixin],
await relayDetails('static/components/relay-details/relay-details.html') data() {
return {
new Vue({ filter: '',
el: '#vue', relayLinks: [],
mixins: [windowMixin], formDialogRelay: {
data: function () { show: false,
return { data: {
filter: '',
relayLinks: [],
formDialogRelay: {
show: false,
data: {
id: '',
name: '',
description: '',
pubkey: '',
contact: ''
}
},
relaysTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'toggle',
align: 'left',
label: 'Active',
field: ''
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
},
{
name: 'contact',
align: 'left',
label: 'Contact',
field: 'contact'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
methods: {
getDefaultRelayData: function () {
return {
id: '', id: '',
name: '', name: '',
description: '', description: '',
@ -83,99 +16,158 @@ const relays = async () => {
} }
}, },
openCreateRelayDialog: function () { relaysTable: {
this.formDialogRelay.data = this.getDefaultRelayData() columns: [
this.formDialogRelay.show = true {
}, name: '',
getRelays: async function () { align: 'left',
try { label: '',
const {data} = await LNbits.api.request( field: ''
'GET', },
'/nostrrelay/api/v1/relay', {
this.g.user.wallets[0].inkey name: 'id',
) align: 'left',
this.relayLinks = data.map(c => label: 'ID',
mapRelay( field: 'id'
c, },
this.relayLinks.find(old => old.id === c.id) {
) name: 'toggle',
) align: 'left',
} catch (error) { label: 'Active',
LNbits.utils.notifyApiError(error) field: ''
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
},
{
name: 'contact',
align: 'left',
label: 'Contact',
field: 'contact'
}
],
pagination: {
rowsPerPage: 10
} }
}, }
}
createRelay: async function (data) { },
try { methods: {
const resp = await LNbits.api.request( getDefaultRelayData: function () {
'POST', return {
'/nostrrelay/api/v1/relay', id: '',
this.g.user.wallets[0].adminkey, name: '',
data description: '',
) pubkey: '',
contact: ''
this.relayLinks.unshift(mapRelay(resp.data))
this.formDialogRelay.show = false
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
showToggleRelayDialog: function (relay) {
if (relay.active) {
this.toggleRelay(relay)
return
}
LNbits.utils
.confirmDialog('Are you sure you want to deactivate this relay?')
.onOk(async () => {
this.toggleRelay(relay)
})
.onCancel(async () => {
relay.active = !relay.active
})
},
toggleRelay: async function (relay) {
try {
await LNbits.api.request(
'PUT',
'/nostrrelay/api/v1/relay/' + relay.id,
this.g.user.wallets[0].adminkey,
{}
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataRelay: async function () {
this.createRelay(this.formDialogRelay.data)
},
handleRelayDeleted: function (relayId) {
this.relayLinks = _.reject(this.relayLinks, function (obj) {
return obj.id === relayId
})
},
handleRelayUpdated: function (relay) {
const index = this.relayLinks.findIndex(r => r.id === relay.id)
if (index !== -1) {
relay.expanded = true
this.relayLinks.splice(index, 1, relay)
}
},
exportrelayCSV: function () {
LNbits.utils.exportCSV(
this.relaysTable.columns,
this.relayLinks,
'relays'
)
} }
}, },
created: async function () {
await this.getRelays()
}
})
}
relays() openCreateRelayDialog: function () {
this.formDialogRelay.data = this.getDefaultRelayData()
this.formDialogRelay.show = true
},
getRelays: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrrelay/api/v1/relay',
this.g.user.wallets[0].inkey
)
this.relayLinks = data.map(c =>
mapRelay(
c,
this.relayLinks.find(old => old.id === c.id)
)
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
createRelay: async function (data) {
try {
const resp = await LNbits.api.request(
'POST',
'/nostrrelay/api/v1/relay',
this.g.user.wallets[0].adminkey,
data
)
this.relayLinks.unshift(mapRelay(resp.data))
this.formDialogRelay.show = false
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
showToggleRelayDialog: function (relay) {
if (relay.active) {
this.toggleRelay(relay)
return
}
LNbits.utils
.confirmDialog('Are you sure you want to deactivate this relay?')
.onOk(async () => {
this.toggleRelay(relay)
})
.onCancel(async () => {
relay.active = !relay.active
})
},
toggleRelay: async function (relay) {
try {
await LNbits.api.request(
'PUT',
'/nostrrelay/api/v1/relay/' + relay.id,
this.g.user.wallets[0].adminkey,
{}
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataRelay: async function () {
this.createRelay(this.formDialogRelay.data)
},
handleRelayDeleted: function (relayId) {
this.relayLinks = _.reject(this.relayLinks, function (obj) {
return obj.id === relayId
})
},
handleRelayUpdated: function (relay) {
const index = this.relayLinks.findIndex(r => r.id === relay.id)
if (index !== -1) {
relay.expanded = true
this.relayLinks.splice(index, 1, relay)
}
},
exportrelayCSV: function () {
LNbits.utils.exportCSV(
this.relaysTable.columns,
this.relayLinks,
'relays'
)
}
},
created: async function () {
await this.getRelays()
}
})

View file

@ -5,22 +5,3 @@ const mapRelay = (obj, oldObj = {}) => {
return relay return relay
} }
function loadTemplateAsync(path) {
const result = new Promise(resolve => {
const xhttp = new XMLHttpRequest()
xhttp.onreadystatechange = function () {
if (this.readyState == 4) {
if (this.status == 200) resolve(this.responseText)
if (this.status == 404) resolve(`<div>Page not found: ${path}</div>`)
}
}
xhttp.open('GET', path, true)
xhttp.send()
})
return result
}

View file

@ -3,7 +3,6 @@ import json
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import websocket_updater from lnbits.core.services import websocket_updater
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
@ -13,7 +12,7 @@ from .models import NostrAccount
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name()) register_invoice_listener(invoice_queue, "ext_nostrrelay")
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -65,43 +64,36 @@ async def on_invoice_paid(payment: Payment):
async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int): async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int):
try: account = await get_account(relay_id, pubkey)
account = await get_account(relay_id, pubkey) if not account:
if not account: account = NostrAccount(
await create_account( relay_id=relay_id, pubkey=pubkey, paid_to_join=True, sats=amount
relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True, sats=amount) )
) await create_account(account)
return return
if account.blocked or account.paid_to_join: if account.blocked or account.paid_to_join:
return return
account.paid_to_join = True account.paid_to_join = True
account.sats += amount account.sats += amount
await update_account(relay_id, account) await update_account(account)
except Exception as ex:
logger.warning(ex)
async def invoice_paid_for_storage( async def invoice_paid_for_storage(
relay_id: str, pubkey: str, storage_to_buy: int, amount: int relay_id: str, pubkey: str, storage_to_buy: int, amount: int
): ):
try: account = await get_account(relay_id, pubkey)
account = await get_account(relay_id, pubkey) if not account:
if not account: account = NostrAccount(
await create_account( relay_id=relay_id, pubkey=pubkey, storage=storage_to_buy, sats=amount
relay_id, )
NostrAccount(pubkey=pubkey, storage=storage_to_buy, sats=amount), await create_account(account)
) return
return
if account.blocked: if account.blocked:
return return
account.storage = storage_to_buy account.storage = storage_to_buy
account.sats += amount account.sats += amount
await update_account(relay_id, account) await update_account(account)
except Exception as ex:
logger.warning(ex)

View file

@ -1,10 +1,10 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<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-card-section> <q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="openCreateRelayDialog()" <q-btn unelevated color="primary" @click="openCreateRelayDialog()"
>New relay >New relay
</q-btn> </q-btn>
@ -49,10 +49,10 @@
<q-table <q-table
flat flat
dense dense
:data="relayLinks" :rows="relayLinks"
row-key="id" row-key="id"
:columns="relaysTable.columns" :columns="relaysTable.columns"
:pagination.sync="relaysTable.pagination" v-model:pagination="relaysTable.pagination"
:filter="filter" :filter="filter"
> >
<template v-slot:body="props"> <template v-slot:body="props">
@ -69,28 +69,37 @@
</q-td> </q-td>
<q-td key="id" :props="props"> <q-td key="id" :props="props">
<a style="color: unset" :href="props.row.id" target="_blank"> <a
{{props.row.id}}</a style="color: unset"
> :href="props.row.id"
target="_blank"
v-text="props.row.id"
></a>
</q-td> </q-td>
<q-td key="toggle" :props="props"> <q-td key="toggle" :props="props">
<q-toggle <q-toggle
size="sm" size="sm"
color="secodary" color="secodary"
v-model="props.row.active" v-model="props.row.active"
@input="showToggleRelayDialog(props.row)" @update:model-value="showToggleRelayDialog(props.row)"
></q-toggle> ></q-toggle>
</q-td> </q-td>
<q-td auto-width> {{props.row.name}} </q-td> <q-td auto-width v-text="props.row.name"></q-td>
<q-td key="description" :props="props"> <q-td
{{props.row.description}} key="description"
</q-td> :props="props"
<q-td key="pubkey" :props="props"> v-text="props.row.description"
<div>{{props.row.pubkey}}</div> ></q-td>
</q-td> <q-td
<q-td key="contact" :props="props"> key="pubkey"
<div>{{props.row.contact}}</div> :props="props"
</q-td> v-text="props.row.pubkey"
></q-td>
<q-td
key="contact"
:props="props"
v-text="props.row.contact"
></q-td>
</q-tr> </q-tr>
<q-tr v-if="props.row.expanded" :props="props"> <q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%"> <q-td colspan="100%">
@ -109,7 +118,6 @@
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -188,9 +196,14 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
{% endblock %} {% block vue_templates %}
<template id="relay-details">
{% include("nostrrelay/relay-details.html") %}
</template>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ url_for('nostrrelay_static', path='js/utils.js') }}"></script> <script src="{{ static_url_for('nostrrelay/static', path='js/utils.js') }}"></script>
<script src="{{ url_for('nostrrelay_static', path='components/relay-details/relay-details.js') }}"></script> <script src="{{ static_url_for('nostrrelay/static', path='js/index.js') }}"></script>
<script src="{{ url_for('nostrrelay_static', path='js/index.js') }}"></script> <script src="{{ static_url_for('nostrrelay/static', path='components/relay-details.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -43,7 +43,7 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-section v-if="relay.config.isPaidRelay"> <q-card-section v-if="relay.meta.isPaidRelay">
<div class="row"> <div class="row">
<div class="col-2 q-pt-sm"> <div class="col-2 q-pt-sm">
<span class="text-bold">Public Key:</span> <span class="text-bold">Public Key:</span>
@ -60,19 +60,19 @@
<div class="col-2"></div> <div class="col-2"></div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-section v-if="relay.config.isPaidRelay"> <q-card-section v-if="relay.meta.isPaidRelay">
<div class="row"> <div class="row">
<div class="col-2"> <div class="col-2">
<span class="text-bold">Cost to join: </span> <span class="text-bold">Cost to join: </span>
</div> </div>
<div class="col-6"> <div class="col-6">
<div> <div>
<span v-text="relay.config.costToJoin"></span> <span v-text="relay.meta.costToJoin"></span>
<span class="text-bold q-ml-sm">sats</span> <span class="text-bold q-ml-sm">sats</span>
</div> </div>
</div> </div>
<div class="col-4"> <div class="col-4">
<div v-if="relay.config.costToJoin"> <div v-if="relay.meta.costToJoin">
<q-btn <q-btn
@click="createInvoice('join')" @click="createInvoice('join')"
unelevated unelevated
@ -89,37 +89,37 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-section v-if="relay.config.isPaidRelay"> <q-card-section v-if="relay.meta.isPaidRelay">
<div class="row q-mt-md q-mb-md"> <div class="row q-mt-md q-mb-md">
<div class="col-2 q-pt-sm"> <div class="col-2 q-pt-sm">
<span class="text-bold">Storage cost: </span> <span class="text-bold">Storage cost: </span>
</div> </div>
<div class="col-3 q-pt-sm"> <div class="col-3 q-pt-sm">
<span v-text="relay.config.storageCostValue"></span> <span v-text="relay.meta.storageCostValue"></span>
<span class="text-bold q-ml-sm"> sats per</span> <span class="text-bold q-ml-sm"> sats per</span>
<q-badge color="orange"> <q-badge color="orange">
<span v-text="relay.config.storageCostUnit"></span> <span v-text="relay.meta.storageCostUnit"></span>
</q-badge> </q-badge>
</div> </div>
<div class="col-2"> <div class="col-2">
<q-input <q-input
v-if="relay.config.storageCostValue" v-if="relay.meta.storageCostValue"
filled filled
dense dense
v-model="unitsToBuy" v-model="unitsToBuy"
type="number" type="number"
min="0" min="0"
:label="relay.config.storageCostUnit" :label="relay.meta.storageCostUnit"
></q-input> ></q-input>
</div> </div>
<div class="col-2 q-pt-sm"> <div class="col-2 q-pt-sm">
<div v-if="relay.config.storageCostValue"> <div v-if="relay.meta.storageCostValue">
<span class="text-bold q-ml-md" v-text="storageCost"></span> <span class="text-bold q-ml-md" v-text="storageCost"></span>
<span>sats</span> <span>sats</span>
</div> </div>
</div> </div>
<div class="col-3"> <div class="col-3">
<div v-if="relay.config.storageCostValue"> <div v-if="relay.meta.storageCostValue">
<q-btn <q-btn
@click="createInvoice('storage')" @click="createInvoice('storage')"
unelevated unelevated
@ -197,12 +197,10 @@
</q-page-container> </q-page-container>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data() {
return { return {
relay: JSON.parse('{{relay | tojson | safe}}'), relay: JSON.parse('{{relay | tojson | safe}}'),
pubkey: '', pubkey: '',
@ -213,14 +211,14 @@
}, },
computed: { computed: {
storageCost: function () { storageCost: function () {
if (!this.relay || !this.relay.config.storageCostValue) return 0 if (!this.relay || !this.relay.meta.storageCostValue) return 0
return this.unitsToBuy * this.relay.config.storageCostValue return this.unitsToBuy * this.relay.meta.storageCostValue
}, },
wssLink: function () { wssLink: function () {
this.relay.config.domain = this.relay.meta.domain =
this.relay.config.domain || window.location.hostname this.relay.meta.domain || window.location.hostname
return ( return (
'wss://' + this.relay.config.domain + '/nostrrelay/' + this.relay.id 'wss://' + this.relay.meta.domain + '/nostrrelay/' + this.relay.id
) )
} }
}, },
@ -272,7 +270,7 @@
wsConnection.close() wsConnection.close()
} }
} catch (error) { } catch (error) {
this.$q.notify({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
type: 'warning', type: 'warning',
message: 'Failed to get invoice status', message: 'Failed to get invoice status',
@ -280,8 +278,7 @@
}) })
} }
} }
}, }
created: function () {}
}) })
</script> </script>
{% endblock %} {% endblock %}

View file

@ -62,7 +62,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.domain" v-model.trim="relay.meta.domain"
type="text" type="text"
></q-input> ></q-input>
</div> </div>
@ -93,7 +93,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.freeStorageValue" v-model.trim="relay.meta.freeStorageValue"
type="number" type="number"
hint="Value" hint="Value"
min="0" min="0"
@ -103,7 +103,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.freeStorageUnit" v-model="relay.meta.freeStorageUnit"
type="text" type="text"
hint="Unit" hint="Unit"
:options="storageUnits" :options="storageUnits"
@ -119,7 +119,7 @@
</div> </div>
<div class="col-md-4 col-sm-2"> <div class="col-md-4 col-sm-2">
<q-badge <q-badge
v-if="relay.config.freeStorageValue == 0" v-if="relay.meta.freeStorageValue == 0"
color="orange" color="orange"
class="float-right q-mb-md" class="float-right q-mb-md"
><span>No free storage</span> ><span>No free storage</span>
@ -132,13 +132,13 @@
<div class="col-md-3 q-pr-lg"> <div class="col-md-3 q-pr-lg">
<q-toggle <q-toggle
color="secodary" color="secodary"
v-model="relay.config.isPaidRelay" v-model="relay.meta.isPaidRelay"
@input="togglePaidRelay" @update:model-value="togglePaidRelay"
></q-toggle> ></q-toggle>
</div> </div>
<div class="col-6"> <div class="col-6">
<q-badge <q-badge
v-if="!relay.config.isPaidRelay && relay.config.freeStorageValue == 0" v-if="!relay.meta.isPaidRelay && relay.meta.freeStorageValue == 0"
color="orange" color="orange"
class="float-right q-mb-md" class="float-right q-mb-md"
><span>No data will be stored. Read-only relay.</span> ><span>No data will be stored. Read-only relay.</span>
@ -146,7 +146,7 @@
</div> </div>
</div> </div>
<div v-if="relay.config.isPaidRelay && relay.config.wallet"> <div v-if="relay.meta.isPaidRelay && relay.meta.wallet">
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div> <div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-md-6 col-sm-8 q-pr-lg"> <div class="col-md-6 col-sm-8 q-pr-lg">
@ -154,7 +154,7 @@
filled filled
dense dense
emit-value emit-value
v-model="relay.config.wallet" v-model="relay.meta.wallet"
:options="walletOptions" :options="walletOptions"
label="Wallet *" label="Wallet *"
> >
@ -174,7 +174,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.costToJoin" v-model.trim="relay.meta.costToJoin"
type="number" type="number"
hint="sats" hint="sats"
min="0" min="0"
@ -189,7 +189,7 @@
</div> </div>
<div class="col-md-6 col-sm-4"> <div class="col-md-6 col-sm-4">
<q-badge <q-badge
v-if="relay.config.costToJoin == 0" v-if="relay.meta.costToJoin == 0"
color="green" color="green"
class="float-right" class="float-right"
><span>Free to join</span> ><span>Free to join</span>
@ -202,7 +202,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.storageCostValue" v-model.trim="relay.meta.storageCostValue"
type="number" type="number"
hint="sats" hint="sats"
min="0" min="0"
@ -212,7 +212,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.storageCostUnit" v-model="relay.meta.storageCostUnit"
type="text" type="text"
hint="Unit" hint="Unit"
:options="storageUnits" :options="storageUnits"
@ -227,7 +227,7 @@
</div> </div>
<div class="col-md-4 col-sm-0"> <div class="col-md-4 col-sm-0">
<q-badge <q-badge
v-if="relay.config.storageCostValue == 0" v-if="relay.meta.storageCostValue == 0"
color="green" color="green"
class="float-right" class="float-right"
><span>Unlimited storage</span> ><span>Unlimited storage</span>
@ -245,7 +245,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.createdAtDaysPast" v-model.trim="relay.meta.createdAtDaysPast"
type="number" type="number"
min="0" min="0"
hint="Days" hint="Days"
@ -255,7 +255,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.createdAtHoursPast" v-model="relay.meta.createdAtHoursPast"
type="number" type="number"
hint="Hours" hint="Hours"
:options="hours" :options="hours"
@ -265,7 +265,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.createdAtMinutesPast" v-model="relay.meta.createdAtMinutesPast"
type="number" type="number"
hint="Minutes" hint="Minutes"
:options="range60" :options="range60"
@ -275,7 +275,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.createdAtSecondsPast" v-model="relay.meta.createdAtSecondsPast"
type="number" type="number"
hint="Seconds" hint="Seconds"
:options="range60" :options="range60"
@ -296,7 +296,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.createdAtDaysFuture" v-model.trim="relay.meta.createdAtDaysFuture"
type="number" type="number"
min="0" min="0"
hint="Days" hint="Days"
@ -306,7 +306,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.createdAtHoursFuture" v-model="relay.meta.createdAtHoursFuture"
type="number" type="number"
hint="Hours" hint="Hours"
:options="hours" :options="hours"
@ -316,7 +316,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.createdAtMinutesFuture" v-model="relay.meta.createdAtMinutesFuture"
type="number" type="number"
hint="Minutes" hint="Minutes"
:options="range60" :options="range60"
@ -326,7 +326,7 @@
<q-select <q-select
filled filled
dense dense
v-model="relay.config.createdAtSecondsFuture" v-model="relay.meta.createdAtSecondsFuture"
type="number" type="number"
hint="Seconds" hint="Seconds"
:options="range60" :options="range60"
@ -348,7 +348,7 @@
<q-toggle <q-toggle
color="secodary" color="secodary"
class="q-ml-md q-mr-md" class="q-ml-md q-mr-md"
v-model="relay.config.requireAuthFilter" v-model="relay.meta.requireAuthFilter"
>For Filters</q-toggle >For Filters</q-toggle
> >
</div> </div>
@ -356,7 +356,7 @@
<q-toggle <q-toggle
color="secodary" color="secodary"
class="q-ml-md q-mr-md" class="q-ml-md q-mr-md"
v-model="relay.config.requireAuthEvents" v-model="relay.meta.requireAuthEvents"
>For All Events</q-toggle >For All Events</q-toggle
> >
</div> </div>
@ -370,7 +370,7 @@
</div> </div>
</div> </div>
<div <div
v-if="relay.config.requireAuthEvents" v-if="relay.meta.requireAuthEvents"
class="row items-center no-wrap q-mb-md q-mt-md" class="row items-center no-wrap q-mb-md q-mt-md"
> >
<div class="col-3 q-pr-lg">Skip Auth For Events:</div> <div class="col-3 q-pr-lg">Skip Auth For Events:</div>
@ -393,14 +393,14 @@
</div> </div>
<div class="col-7"> <div class="col-7">
<q-chip <q-chip
v-for="e in relay.config.skipedAuthEvents" v-for="e in relay.meta.skipedAuthEvents"
:key="e" :key="e"
removable removable
@remove="removeSkipAuthForEvent(e)" @remove="removeSkipAuthForEvent(e)"
color="primary" color="primary"
text-color="white" text-color="white"
> >
{{ e }} <span v-text="e"></span>
</q-chip> </q-chip>
</div> </div>
</div> </div>
@ -425,14 +425,14 @@
</div> </div>
<div class="col-7"> <div class="col-7">
<q-chip <q-chip
v-for="e in relay.config.forcedAuthEvents" v-for="e in relay.meta.forcedAuthEvents"
:key="e" :key="e"
removable removable
@remove="removeForceAuthForEvent(e)" @remove="removeForceAuthForEvent(e)"
color="primary" color="primary"
text-color="white" text-color="white"
> >
{{ e }} <span v-text="e"></span>
</q-chip> </q-chip>
</div> </div>
</div> </div>
@ -444,7 +444,7 @@
filled filled
dense dense
emit-value emit-value
v-model="relay.config.fullStorageAction" v-model="relay.meta.fullStorageAction"
type="text" type="text"
:options="fullStorageActions" :options="fullStorageActions"
></q-select> ></q-select>
@ -464,7 +464,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.limitPerFilter" v-model.trim="relay.meta.limitPerFilter"
type="number" type="number"
min="0" min="0"
></q-input> ></q-input>
@ -477,7 +477,7 @@
</q-tooltip></q-icon </q-tooltip></q-icon
> >
<q-badge <q-badge
v-if="relay.config.limitPerFilter == 0" v-if="relay.meta.limitPerFilter == 0"
color="green" color="green"
class="float-right" class="float-right"
><span>No Limit</span> ><span>No Limit</span>
@ -490,7 +490,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.maxClientFilters" v-model.trim="relay.meta.maxClientFilters"
type="number" type="number"
min="0" min="0"
></q-input> ></q-input>
@ -504,7 +504,7 @@
</q-tooltip></q-icon </q-tooltip></q-icon
> >
<q-badge <q-badge
v-if="relay.config.maxClientFilters == 0" v-if="relay.meta.maxClientFilters == 0"
color="green" color="green"
class="float-right" class="float-right"
><span>Unlimited Filters</span> ><span>Unlimited Filters</span>
@ -517,7 +517,7 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="relay.config.maxEventsPerHour" v-model.trim="relay.meta.maxEventsPerHour"
type="number" type="number"
min="0" min="0"
></q-input> ></q-input>
@ -530,7 +530,7 @@
</q-tooltip></q-icon </q-tooltip></q-icon
> >
<q-badge <q-badge
v-if="relay.config.maxEventsPerHour == 0" v-if="relay.meta.maxEventsPerHour == 0"
color="green" color="green"
class="float-right" class="float-right"
><span>No Limit</span> ><span>No Limit</span>
@ -584,7 +584,7 @@
color="secodary" color="secodary"
class="q-mr-lg" class="q-mr-lg"
v-model="showAllowedAccounts" v-model="showAllowedAccounts"
@input="getAccounts()" @update:model-value="getAccounts()"
>Show Allowed Account</q-toggle >Show Allowed Account</q-toggle
> >
<q-toggle <q-toggle
@ -592,7 +592,7 @@
color="secodary" color="secodary"
class="q-mr-lg" class="q-mr-lg"
v-model="showBlockedAccounts" v-model="showBlockedAccounts"
@input="getAccounts()" @update:model-value="getAccounts()"
> >
Show Blocked Accounts</q-toggle Show Blocked Accounts</q-toggle
> >
@ -605,7 +605,7 @@
<q-table <q-table
flat flat
dense dense
:data="accounts" :rows="accounts"
row-key="pubkey" row-key="pubkey"
:columns="accountsTable.columns" :columns="accountsTable.columns"
:pagination.sync="accountsTable.pagination" :pagination.sync="accountsTable.pagination"
@ -623,14 +623,14 @@
> >
</q-td> </q-td>
<q-td key="pubkey" :props="props"> <q-td key="pubkey" :props="props">
{{props.row.pubkey}} <span v-text="props.row.pubkey"></span>
</q-td> </q-td>
<q-td key="allowed" :props="props"> <q-td key="allowed" :props="props">
<q-toggle <q-toggle
size="sm" size="sm"
color="secodary" color="secodary"
v-model="props.row.allowed" v-model="props.row.allowed"
@input="togglePublicKey(props.row, 'allow')" @update:model-value="togglePublicKey(props.row, 'allow')"
></q-toggle> ></q-toggle>
</q-td> </q-td>
<q-td key="blocked" :props="props"> <q-td key="blocked" :props="props">
@ -638,12 +638,17 @@
size="sm" size="sm"
color="secodary" color="secodary"
v-model="props.row.blocked" v-model="props.row.blocked"
@input="togglePublicKey(props.row, 'block')" @update:model-value="togglePublicKey(props.row, 'block')"
></q-toggle> ></q-toggle>
</q-td> </q-td>
<q-td auto-width> {{props.row.paid_to_join}} </q-td>
<q-td auto-width> {{props.row.sats}} </q-td> <q-td auto-width
<q-td auto-width> {{props.row.storage}} </q-td> ><span v-text="props.row.paid_to_join"></span>
</q-td>
<q-td auto-width> <span v-text="props.row.sats"></span></q-td>
<q-td auto-width
><span v-text="props.row.storage"></span>
</q-td>
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>

View file

@ -61,7 +61,7 @@ async def test_valid_event_crud(valid_events: List[EventFixture]):
# insert all events in DB before doing an query # insert all events in DB before doing an query
for e in all_events: for e in all_events:
await create_event(RELAY_ID, e, None) await create_event(e)
for f in valid_events: for f in valid_events:
await get_by_id(f.data, f.name) await get_by_id(f.data, f.name)

View file

@ -1,18 +1,14 @@
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.exceptions import HTTPException from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from starlette.responses import HTMLResponse
from .crud import get_public_relay from .crud import get_public_relay
from .helpers import relay_info_response from .helpers import relay_info_response
templates = Jinja2Templates(directory="templates")
nostrrelay_generic_router: APIRouter = APIRouter() nostrrelay_generic_router: APIRouter = APIRouter()
@ -23,7 +19,7 @@ def nostrrelay_renderer():
@nostrrelay_generic_router.get("/", response_class=HTMLResponse) @nostrrelay_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(request: Request, user: User = Depends(check_user_exists)):
return nostrrelay_renderer().TemplateResponse( return nostrrelay_renderer().TemplateResponse(
"nostrrelay/index.html", {"request": request, "user": user.dict()} "nostrrelay/index.html", {"request": request, "user": user.json()}
) )

View file

@ -1,8 +1,7 @@
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import Optional
from fastapi import APIRouter, Depends, Request, WebSocket from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from fastapi.exceptions import HTTPException
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
@ -38,6 +37,7 @@ nostrrelay_api_router = APIRouter()
@nostrrelay_api_router.websocket("/{relay_id}") @nostrrelay_api_router.websocket("/{relay_id}")
@nostrrelay_api_router.websocket("/{relay_id}/")
async def websocket_endpoint(relay_id: str, websocket: WebSocket): async def websocket_endpoint(relay_id: str, websocket: WebSocket):
client = NostrClientConnection(relay_id=relay_id, websocket=websocket) client = NostrClientConnection(relay_id=relay_id, websocket=websocket)
client_accepted = await client_manager.add_client(client) client_accepted = await client_manager.add_client(client)
@ -55,26 +55,19 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket):
async def api_create_relay( async def api_create_relay(
data: NostrRelay, data: NostrRelay,
request: Request, request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
) -> NostrRelay: ) -> NostrRelay:
data.user_id = key_info.wallet.user
if len(data.id): if len(data.id):
user = await get_user(wallet.wallet.user) user = await get_user(data.user_id)
assert user, "User not found." assert user, "User not found."
assert user.admin, "Only admin users can set the relay ID" assert user.admin, "Only admin users can set the relay ID"
else: else:
data.id = urlsafe_short_hash()[:8] data.id = urlsafe_short_hash()[:8]
try: data.meta.domain = extract_domain(str(request.url))
data.config.domain = extract_domain(str(request.url)) relay = await create_relay(data)
relay = await create_relay(wallet.wallet.user, data) return relay
return relay
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create relay",
) from ex
@nostrrelay_api_router.patch("/api/v1/relay/{relay_id}") @nostrrelay_api_router.patch("/api/v1/relay/{relay_id}")
@ -87,79 +80,54 @@ async def api_update_relay(
detail="Cannot change the relay id", detail="Cannot change the relay id",
) )
try: relay = await get_relay(wallet.wallet.user, data.id)
relay = await get_relay(wallet.wallet.user, data.id) if not relay:
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)})
updated_relay = await update_relay(wallet.wallet.user, updated_relay)
# activate & deactivate have their own endpoint
updated_relay.active = relay.active
if updated_relay.active:
await client_manager.enable_relay(relay_id, updated_relay.config)
else:
await client_manager.disable_relay(relay_id)
return updated_relay
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.NOT_FOUND,
detail="Cannot update relay", detail="Relay not found",
) from ex )
updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)})
updated_relay.user_id = wallet.wallet.user
updated_relay = await update_relay(updated_relay)
# activate & deactivate have their own endpoint
updated_relay.active = relay.active
if updated_relay.active:
await client_manager.enable_relay(relay_id, updated_relay.meta)
else:
await client_manager.disable_relay(relay_id)
return updated_relay
@nostrrelay_api_router.put("/api/v1/relay/{relay_id}") @nostrrelay_api_router.put("/api/v1/relay/{relay_id}")
async def api_toggle_relay( async def api_toggle_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> NostrRelay: ) -> NostrRelay:
relay = await get_relay(wallet.wallet.user, relay_id)
try: if not relay:
relay = await get_relay(wallet.wallet.user, relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
relay.active = not relay.active
updated_relay = await update_relay(wallet.wallet.user, relay)
if relay.active:
await client_manager.enable_relay(relay_id, relay.config)
else:
await client_manager.disable_relay(relay_id)
return updated_relay
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.NOT_FOUND,
detail="Cannot update relay", detail="Relay not found",
) from ex )
relay.active = not relay.active
await update_relay(relay)
if relay.active:
await client_manager.enable_relay(relay_id, relay.meta)
else:
await client_manager.disable_relay(relay_id)
return relay
@nostrrelay_api_router.get("/api/v1/relay") @nostrrelay_api_router.get("/api/v1/relay")
async def api_get_relays( async def api_get_relays(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[NostrRelay]: ) -> list[NostrRelay]:
try: return await get_relays(wallet.wallet.user)
return await get_relays(wallet.wallet.user)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch relays",
) from ex
@nostrrelay_api_router.get("/api/v1/relay-info") @nostrrelay_api_router.get("/api/v1/relay-info")
@ -171,14 +139,7 @@ async def api_get_relay_info() -> JSONResponse:
async def api_get_relay( async def api_get_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key) relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> Optional[NostrRelay]: ) -> Optional[NostrRelay]:
try: relay = await get_relay(wallet.wallet.user, relay_id)
relay = await get_relay(wallet.wallet.user, relay_id)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch relay",
) from ex
if not relay: if not relay:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
@ -191,39 +152,23 @@ async def api_get_relay(
async def api_create_or_update_account( async def api_create_or_update_account(
data: NostrPartialAccount, data: NostrPartialAccount,
) -> NostrAccount: ) -> NostrAccount:
data.pubkey = normalize_public_key(data.pubkey)
account = await get_account(data.relay_id, data.pubkey)
if not account:
account = NostrAccount(
pubkey=data.pubkey,
relay_id=data.relay_id,
blocked=data.blocked or False,
allowed=data.allowed or False,
)
return await create_account(account)
try: if data.blocked is not None:
data.pubkey = normalize_public_key(data.pubkey) account.blocked = data.blocked
if data.allowed is not None:
account.allowed = data.allowed
account = await get_account(data.relay_id, data.pubkey) return await update_account(account)
if not account:
account = NostrAccount(
pubkey=data.pubkey,
blocked=data.blocked or False,
allowed=data.allowed or False,
)
return await create_account(data.relay_id, account)
if data.blocked is not None:
account.blocked = data.blocked
if data.allowed is not None:
account.allowed = data.allowed
return await update_account(data.relay_id, account)
except ValueError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create account",
) from ex
@nostrrelay_api_router.delete( @nostrrelay_api_router.delete(
@ -249,30 +194,16 @@ async def api_get_accounts(
allowed: bool = False, allowed: bool = False,
blocked: bool = True, blocked: bool = True,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[NostrAccount]: ) -> list[NostrAccount]:
try: # make sure the user has access to the relay
# make sure the user has access to the relay relay = await get_relay(wallet.wallet.user, relay_id)
relay = await get_relay(wallet.wallet.user, relay_id) if not relay:
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
accounts = await get_accounts(relay.id, allowed, blocked)
return accounts
except ValueError as ex:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.NOT_FOUND,
detail=str(ex), detail="Relay not found",
) from ex )
except HTTPException as ex: accounts = await get_accounts(relay.id, allowed, blocked)
raise ex return accounts
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch accounts",
) from ex
@nostrrelay_api_router.delete("/api/v1/relay/{relay_id}") @nostrrelay_api_router.delete("/api/v1/relay/{relay_id}")
@ -305,9 +236,9 @@ async def api_pay_to_join(data: BuyOrder):
if data.action == "join": if data.action == "join":
if relay.is_free_to_join: if relay.is_free_to_join:
raise ValueError("Relay is free to join") raise ValueError("Relay is free to join")
amount = int(relay.config.cost_to_join) amount = int(relay.meta.cost_to_join)
elif data.action == "storage": elif data.action == "storage":
if relay.config.storage_cost_value == 0: if relay.meta.storage_cost_value == 0:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Relay storage cost is zero. Cannot buy!", detail="Relay storage cost is zero. Cannot buy!",
@ -317,18 +248,18 @@ async def api_pay_to_join(data: BuyOrder):
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Must specify how much storage to buy!", detail="Must specify how much storage to buy!",
) )
storage_to_buy = data.units_to_buy * relay.config.storage_cost_value * 1024 storage_to_buy = data.units_to_buy * relay.meta.storage_cost_value * 1024
if relay.config.storage_cost_unit == "MB": if relay.meta.storage_cost_unit == "MB":
storage_to_buy *= 1024 storage_to_buy *= 1024
amount = data.units_to_buy * relay.config.storage_cost_value amount = data.units_to_buy * relay.meta.storage_cost_value
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"Unknown action: '{data.action}'", detail=f"Unknown action: '{data.action}'",
) )
_, payment_request = await create_invoice( payment = await create_invoice(
wallet_id=relay.config.wallet, wallet_id=relay.meta.wallet,
amount=amount, amount=amount,
memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}", memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}",
extra={ extra={
@ -339,4 +270,4 @@ async def api_pay_to_join(data: BuyOrder):
"storage_to_buy": storage_to_buy, "storage_to_buy": storage_to_buy,
}, },
) )
return {"invoice": payment_request} return {"invoice": payment.bolt11}