Compare commits

..

No commits in common. "801ce44561f1d8ae4791cdfdfd9dccb70f340071" and "ed67ad32948ea6735fa24c6d3efe136ddeb13f50" have entirely different histories.

15 changed files with 102 additions and 236 deletions

119
README.md
View file

@ -2,123 +2,14 @@
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small> <small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Overview `nostrclient` is an always-on extension that can open multiple connections to nostr relays and act as a multiplexer for other clients: You open a single websocket to `nostrclient` which then sends the data to multiple relays. The responses from these relays are then sent back to the client.
`nostrclient` is an always-on Nostr relay multiplexer that simplifies connecting to multiple Nostr relays. Instead of your Nostr client managing connections to dozens of relays, you connect to a single WebSocket endpoint provided by `nostrclient`, which then fans out your requests to all configured relays and aggregates the responses back to you. ![2023-03-08 18 11 07](https://user-images.githubusercontent.com/93376500/225265727-369f0f8a-196e-41df-a0d1-98b50a0228be.jpg)
### Why Use This? ### Troubleshoot
- **Simplified Client Configuration** - Connect to one endpoint instead of managing multiple relay connections The `Test Endpoint` functionality heps the user to check that the `nostrclient` web-socket endpoint works as expected.
- **Always-On Connectivity** - Your LNbits instance maintains persistent connections to relays
- **Resource Efficient** - Share relay connections across multiple clients
- **Subscription Management** - Automatic subscription ID rewriting prevents conflicts between clients
## Architecture The LNbits user can DM itself (or a temp account) from `nostrclient` and verify that the messages are sent and received correctly.
```mermaid
flowchart LR
A[Client A] -->|WebSocket| N
B[Client B] -->|WebSocket| N
C[Client C] -->|WebSocket| N
N[nostrclient<br/>Router] -->|Fan Out| R1[Relay A]
N -->|Fan Out| R2[Relay B]
N -->|Fan Out| R3[Relay C]
N -->|Fan Out| R4[Relay D]
R1 -.->|Aggregate| N
R2 -.->|Aggregate| N
R3 -.->|Aggregate| N
R4 -.->|Aggregate| N
```
**Key Feature:** The router rewrites subscription IDs to prevent conflicts when multiple clients use the same IDs.
## Features
- **Multi-Relay Multiplexing** - Connect to multiple Nostr relays through a single WebSocket
- **Public & Private Endpoints** - Configurable public and private WebSocket access
- **Automatic Reconnection** - Failed relays are automatically retried with exponential backoff
- **Subscription Deduplication** - Events are deduplicated before being sent to clients
- **Health Monitoring** - Track relay connection status, latency, and error rates
- **Test Endpoint** - Send test messages to verify your setup is working
## How It Works
1. **Client Connection** - Your Nostr client connects to the nostrclient WebSocket endpoint
2. **Subscription Rewriting** - Each subscription ID is rewritten to prevent conflicts between multiple clients
3. **Fan-Out** - Subscription requests are sent to all configured relays
4. **Aggregation** - Events from all relays are collected and deduplicated
5. **Response** - Events are sent back to the client with the original subscription ID
## Configuration
### WebSocket Endpoints
- **Public Endpoint**: `/api/v1/relay` - Available to anyone (if enabled)
- **Private Endpoint**: `/api/v1/{encrypted_id}` - Requires valid encrypted endpoint ID
Configure endpoint access in the extension settings:
- `private_ws` - Enable/disable private WebSocket access
- `public_ws` - Enable/disable public WebSocket access
### Adding Relays
Use the nostrclient UI to add/remove Nostr relays. The extension will automatically:
- Connect to new relays
- Publish existing subscriptions to new relays
- Monitor relay health and reconnect as needed
## Testing
### Test Endpoint Functionality
The `Test Endpoint` feature helps verify that your nostrclient WebSocket endpoint works correctly.
**How to test:**
1. Navigate to the nostrclient extension in LNbits
2. Use the Test Endpoint feature
3. Send a DM to yourself (or a temporary account)
4. Verify that messages are sent and received correctly
https://user-images.githubusercontent.com/2951406/236780745-929c33c2-2502-49be-84a3-db02a7aabc0e.mp4 https://user-images.githubusercontent.com/2951406/236780745-929c33c2-2502-49be-84a3-db02a7aabc0e.mp4
## Troubleshooting
### Connection Issues
- **Check relay status** - View relay health in the nostrclient UI
- **Verify endpoint configuration** - Ensure public_ws or private_ws is enabled
- **Check logs** - Review LNbits logs for connection errors
### Subscription Not Receiving Events
- **Verify relays are connected** - Check the relay status in the UI
- **Test with known event** - Use the Test Endpoint to verify connectivity
- **Check relay compatibility** - Some relays may not support all Nostr features
## Development
This extension uses `uv` for dependency management.
### Quick Start
```bash
# Format code
make format
# Run type checks and linting
make check
# Run tests
make test
```
For more development commands, see the [Makefile](./Makefile).
## License
MIT License - see [LICENSE](./LICENSE)

View file

@ -53,7 +53,7 @@ def nostrclient_start():
__all__ = [ __all__ = [
"db", "db",
"nostrclient_ext", "nostrclient_ext",
"nostrclient_start",
"nostrclient_static_files", "nostrclient_static_files",
"nostrclient_stop", "nostrclient_stop",
"nostrclient_start",
] ]

View file

@ -1,17 +1,7 @@
{ {
"name": "Nostr Client", "name": "Nostr Client",
"short_description": "Nostr relay multiplexer", "short_description": "Nostr client for extensions",
"version": "1.1.0",
"tile": "/nostrclient/static/images/nostr-bitcoin.png", "tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle", "motorina0", "dni"], "contributors": ["calle", "motorina0", "dni"],
"min_lnbits_version": "1.4.0", "min_lnbits_version": "1.0.0"
"images": [
{
"uri": "https://raw.githubusercontent.com/lnbits/nostrclient/add-extension-metadata/static/images/1.jpeg"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/nostrclient/add-extension-metadata/static/images/2.jpeg"
}
],
"description_md": "https://raw.githubusercontent.com/lnbits/nostrclient/add-extension-metadata/description.md"
} }

View file

@ -1,3 +1,5 @@
from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from .models import Config, Relay, UserConfig from .models import Config, Relay, UserConfig
@ -38,7 +40,7 @@ async def update_config(owner_id: str, config: Config) -> Config:
return user_config.extra return user_config.extra
async def get_config(owner_id: str) -> Config | None: async def get_config(owner_id: str) -> Optional[Config]:
user_config: UserConfig = await db.fetchone( user_config: UserConfig = await db.fetchone(
""" """
SELECT * FROM nostrclient.config SELECT * FROM nostrclient.config

View file

@ -1,8 +1 @@
An always-on relay multiplexer that simplifies connecting to multiple Nostr relays. An always-on extension that can open multiple connections to nostr relays and act as a multiplexer for other clients: You open a single websocket to nostrclient which then sends the data to multiple relays. The responses from these relays are then sent back to the client.
Instead of your Nostr client managing connections to dozens of relays, you connect to a single WebSocket endpoint provided by `nostrclient`, which then fans out your requests to all configured relays and aggregates the responses back to you.
- **Simplified Client Configuration** - Connect to one endpoint instead of managing multiple relay connections
- **Always-On Connectivity** - Your LNbits instance maintains persistent connections to relays
- **Resource Efficient** - Share relay connections across multiple clients
- **Automatic Subscription Management** - Subscription ID rewriting prevents conflicts between clients

View file

@ -1,25 +1,27 @@
from typing import Optional
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class RelayStatus(BaseModel): class RelayStatus(BaseModel):
num_sent_events: int | None = 0 num_sent_events: Optional[int] = 0
num_received_events: int | None = 0 num_received_events: Optional[int] = 0
error_counter: int | None = 0 error_counter: Optional[int] = 0
error_list: list | None = [] error_list: Optional[list] = []
notice_list: list | None = [] notice_list: Optional[list] = []
class Relay(BaseModel): class Relay(BaseModel):
id: str | None = None id: Optional[str] = None
url: str | None = None url: Optional[str] = None
active: bool | None = None active: Optional[bool] = None
connected: bool | None = Field(default=None, no_database=True) connected: Optional[bool] = Field(default=None, no_database=True)
connected_string: str | None = Field(default=None, no_database=True) connected_string: Optional[str] = Field(default=None, no_database=True)
status: RelayStatus | None = Field(default=None, no_database=True) status: Optional[RelayStatus] = Field(default=None, no_database=True)
ping: int | None = Field(default=None, no_database=True) ping: Optional[int] = Field(default=None, no_database=True)
def _init__(self): def _init__(self):
if not self.id: if not self.id:
@ -29,11 +31,11 @@ class Relay(BaseModel):
class RelayDb(BaseModel): class RelayDb(BaseModel):
id: str id: str
url: str url: str
active: bool | None = True active: Optional[bool] = True
class TestMessage(BaseModel): class TestMessage(BaseModel):
sender_private_key: str | None sender_private_key: Optional[str]
reciever_public_key: str reciever_public_key: str
message: str message: str

View file

@ -5,7 +5,7 @@ from enum import IntEnum
from hashlib import sha256 from hashlib import sha256
from typing import Optional from typing import Optional
import coincurve from secp256k1 import PublicKey
from .message_type import ClientMessageType from .message_type import ClientMessageType
@ -75,8 +75,12 @@ class Event:
def verify(self) -> bool: def verify(self) -> bool:
assert self.public_key assert self.public_key
assert self.signature assert self.signature
pub_key = coincurve.PublicKeyXOnly(bytes.fromhex(self.public_key)) pub_key = PublicKey(
return pub_key.verify(bytes.fromhex(self.signature), bytes.fromhex(self.id)) bytes.fromhex("02" + self.public_key), True
) # add 02 for schnorr (bip340)
return pub_key.schnorr_verify(
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
)
def to_message(self) -> str: def to_message(self) -> str:
return json.dumps( return json.dumps(

View file

@ -1,7 +1,9 @@
import base64 import base64
import secrets import secrets
from typing import Optional
import coincurve import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@ -21,13 +23,15 @@ class PublicKey:
return self.raw_bytes.hex() return self.raw_bytes.hex()
def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool: def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
pk = coincurve.PublicKeyXOnly(self.raw_bytes) pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
return pk.verify(bytes.fromhex(sig), bytes.fromhex(message_hash)) return pk.schnorr_verify(
bytes.fromhex(message_hash), bytes.fromhex(sig), None, True
)
@classmethod @classmethod
def from_npub(cls, npub: str): def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form""" """Load a PublicKey from its bech32/npub form"""
_, data, _ = bech32_decode(npub) hrp, data, spec = bech32_decode(npub)
raw_data = convertbits(data, 5, 8) raw_data = convertbits(data, 5, 8)
assert raw_data assert raw_data
raw_public_key = raw_data[:-1] raw_public_key = raw_data[:-1]
@ -35,20 +39,20 @@ class PublicKey:
class PrivateKey: class PrivateKey:
def __init__(self, raw_secret: bytes | None = None) -> None: def __init__(self, raw_secret: Optional[bytes] = None) -> None:
if raw_secret is not None: if raw_secret is not None:
self.raw_secret = raw_secret self.raw_secret = raw_secret
else: else:
self.raw_secret = secrets.token_bytes(32) self.raw_secret = secrets.token_bytes(32)
sk = coincurve.PrivateKey(self.raw_secret) sk = secp256k1.PrivateKey(self.raw_secret)
assert sk.public_key assert sk.pubkey
self.public_key = PublicKey(sk.public_key.format()[1:]) self.public_key = PublicKey(sk.pubkey.serialize()[1:])
@classmethod @classmethod
def from_nsec(cls, nsec: str): def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form""" """Load a PrivateKey from its bech32/nsec form"""
_, data, _ = bech32_decode(nsec) hrp, data, spec = bech32_decode(nsec)
raw_data = convertbits(data, 5, 8) raw_data = convertbits(data, 5, 8)
assert raw_data assert raw_data
raw_secret = raw_data[:-1] raw_secret = raw_data[:-1]
@ -62,13 +66,12 @@ class PrivateKey:
return self.raw_secret.hex() return self.raw_secret.hex()
def tweak_add(self, scalar: bytes) -> bytes: def tweak_add(self, scalar: bytes) -> bytes:
sk = coincurve.PrivateKey(self.raw_secret) sk = secp256k1.PrivateKey(self.raw_secret)
return sk.add(scalar).to_der() return sk.tweak_add(scalar)
def compute_shared_secret(self, public_key_hex: str) -> bytes: def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex)) pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
sk = coincurve.PrivateKey(self.raw_secret) return pk.ecdh(self.raw_secret, hashfn=copy_x)
return sk.ecdh(pk.format())
def encrypt_message(self, message: str, public_key_hex: str) -> str: def encrypt_message(self, message: str, public_key_hex: str) -> str:
padder = padding.PKCS7(128).padder() padder = padding.PKCS7(128).padder()
@ -113,8 +116,8 @@ class PrivateKey:
return unpadded_data.decode() return unpadded_data.decode()
def sign_message_hash(self, message_hash: bytes) -> str: def sign_message_hash(self, message_hash: bytes) -> str:
sk = coincurve.PrivateKey(self.raw_secret) sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.sign_schnorr(message_hash) sig = sk.schnorr_sign(message_hash, None, raw=True)
return sig.hex() return sig.hex()
def sign_event(self, event: Event) -> None: def sign_event(self, event: Event) -> None:
@ -128,7 +131,9 @@ class PrivateKey:
return self.raw_secret == other.raw_secret return self.raw_secret == other.raw_secret
def mine_vanity_key(prefix: str | None = None, suffix: str | None = None) -> PrivateKey: def mine_vanity_key(
prefix: Optional[str] = None, suffix: Optional[str] = None
) -> PrivateKey:
if prefix is None and suffix is None: if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
@ -144,3 +149,14 @@ def mine_vanity_key(prefix: str | None = None, suffix: str | None = None) -> Pri
break break
return sk return sk
ffi = FFI()
@ffi.callback(
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
)
def copy_x(output, x32, y32, data):
ffi.memmove(output, x32, 32)
return 1

View file

@ -1,6 +1,6 @@
[project] [project]
name = "lnbits-nostrclient" name = "lnbits-nostrclient"
version = "1.1.0" version = "0.0.0"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@ -19,20 +19,11 @@ dev-dependencies = [
"pre-commit", "pre-commit",
"ruff", "ruff",
"pytest-md", "pytest-md",
"types-cffi",
] ]
[tool.mypy] [tool.mypy]
exclude = "(nostr/*)"
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = [
"nostr.*",
]
follow_imports = "skip"
ignore_missing_imports = "True"
[tool.pydantic-mypy] [tool.pydantic-mypy]
init_forbid_extra = true init_forbid_extra = true
init_typed = true init_typed = true

View file

@ -1,6 +1,6 @@
import asyncio import asyncio
import json import json
from typing import ClassVar from typing import ClassVar, Dict, List
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -16,7 +16,7 @@ all_routers: list["NostrRouter"] = []
class NostrRouter: class NostrRouter:
received_subscription_events: ClassVar[dict[str, list[EventMessage]]] = {} received_subscription_events: ClassVar[dict[str, List[EventMessage]]] = {}
received_subscription_notices: ClassVar[list[NoticeMessage]] = [] received_subscription_notices: ClassVar[list[NoticeMessage]] = []
received_subscription_eosenotices: ClassVar[dict[str, EndOfStoredEventsMessage]] = ( received_subscription_eosenotices: ClassVar[dict[str, EndOfStoredEventsMessage]] = (
{} {}
@ -25,11 +25,11 @@ class NostrRouter:
def __init__(self, websocket: WebSocket): def __init__(self, websocket: WebSocket):
self.connected: bool = True self.connected: bool = True
self.websocket: WebSocket = websocket self.websocket: WebSocket = websocket
self.tasks: list[asyncio.Task] = [] self.tasks: List[asyncio.Task] = []
self.original_subscription_ids: dict[str, str] = {} self.original_subscription_ids: Dict[str, str] = {}
@property @property
def subscriptions(self) -> list[str]: def subscriptions(self) -> List[str]:
return list(self.original_subscription_ids.keys()) return list(self.original_subscription_ids.keys())
def start(self): def start(self):
@ -111,14 +111,10 @@ class NostrRouter:
# this reconstructs the original response from the relay # this reconstructs the original response from the relay
# reconstruct original subscription id # reconstruct original subscription id
s_original = self.original_subscription_ids[s] s_original = self.original_subscription_ids[s]
event_to_forward = json.dumps( event_to_forward = f"""["EVENT", "{s_original}", {event_json}]"""
["EVENT", s_original, json.loads(event_json)]
)
await self.websocket.send_text(event_to_forward) await self.websocket.send_text(event_to_forward)
except Exception as e: except Exception as e:
logger.warning( logger.debug(e) # there are 2900 errors here
f"[NOSTRCLIENT] Error in _handle_received_subscription_events: {e}"
)
def _handle_notices(self): def _handle_notices(self):
while len(NostrRouter.received_subscription_notices): while len(NostrRouter.received_subscription_notices):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

View file

@ -466,7 +466,8 @@
predefinedRelays: [ predefinedRelays: [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net', 'wss://nostr-pub.wellorder.net',
'wss://relay.nostrconnect.com', 'wss://nostr.zebedee.cloud',
'wss://nodestr.fmt.wiz.biz',
'wss://nostr.oxtr.dev', 'wss://nostr.oxtr.dev',
'wss://nostr.wine' 'wss://nostr.wine'
] ]
@ -476,7 +477,11 @@
getRelays: function () { getRelays: function () {
var self = this var self = this
LNbits.api LNbits.api
.request('GET', '/nostrclient/api/v1/relays') .request(
'GET',
'/nostrclient/api/v1/relays?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
response.data.map(maplrelays) response.data.map(maplrelays)
@ -504,9 +509,12 @@
console.log('ADD RELAY ' + this.relayToAdd) console.log('ADD RELAY ' + this.relayToAdd)
let that = this let that = this
LNbits.api LNbits.api
.request('POST', '/nostrclient/api/v1/relay', null, { .request(
url: this.relayToAdd 'POST',
}) '/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: this.relayToAdd}
)
.then(function (response) { .then(function (response) {
console.log('response:', response) console.log('response:', response)
if (response.data) { if (response.data) {
@ -533,7 +541,12 @@
}, },
deleteRelay(url) { deleteRelay(url) {
LNbits.api LNbits.api
.request('DELETE', '/nostrclient/api/v1/relay', null, {url: url}) .request(
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: url}
)
.then(response => { .then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url) const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
if (relayIndex !== -1) { if (relayIndex !== -1) {
@ -549,7 +562,8 @@
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
'/nostrclient/api/v1/config' '/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey
) )
this.config.data = data this.config.data = data
} catch (error) { } catch (error) {
@ -561,7 +575,7 @@
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/config', '/nostrclient/api/v1/config',
null, this.g.user.wallets[0].adminkey,
this.config.data this.config.data
) )
this.config.data = data this.config.data = data
@ -610,7 +624,7 @@
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/relay/test', '/nostrclient/api/v1/relay/test',
null, this.g.user.wallets[0].adminkey,
{ {
sender_private_key: this.testData.senderPrivateKey, sender_private_key: this.testData.senderPrivateKey,
reciever_public_key: this.testData.recieverPublicKey, reciever_public_key: this.testData.recieverPublicKey,

31
uv.lock generated
View file

@ -840,8 +840,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
{ url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
{ url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" },
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
@ -851,8 +849,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" },
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
@ -862,8 +858,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
] ]
@ -1078,7 +1072,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits-nostrclient" name = "lnbits-nostrclient"
version = "1.1.0" version = "0.0.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "lnbits" }, { name = "lnbits" },
@ -1093,7 +1087,6 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-md" }, { name = "pytest-md" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-cffi" },
] ]
[package.metadata] [package.metadata]
@ -1108,7 +1101,6 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-md" }, { name = "pytest-md" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-cffi" },
] ]
[[package]] [[package]]
@ -2040,27 +2032,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" },
] ]
[[package]]
name = "types-cffi"
version = "1.17.0.20250822"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/0c/76a48cb6e742cac4d61a4ec632dd30635b6d302f5acdc2c0a27572ac7ae3/types_cffi-1.17.0.20250822.tar.gz", hash = "sha256:bf6f5a381ea49da7ff895fae69711271e6192c434470ce6139bf2b2e0d0fa08d", size = 17130, upload-time = "2025-08-22T03:04:02.445Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/f7/68029931e7539e3246b33386a19c475f234c71d2a878411847b20bb31960/types_cffi-1.17.0.20250822-py3-none-any.whl", hash = "sha256:183dd76c1871a48936d7b931488e41f0f25a7463abe10b5816be275fc11506d5", size = 20083, upload-time = "2025-08-22T03:04:01.466Z" },
]
[[package]]
name = "types-setuptools"
version = "80.9.0.20250822"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.0" version = "4.14.0"

View file

@ -1,7 +1,6 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from lnbits.core.crud.users import get_user_from_account from lnbits.core.models import User
from lnbits.core.models.users import Account
from lnbits.decorators import check_admin from lnbits.decorators import check_admin
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
@ -13,10 +12,7 @@ def nostr_renderer():
@nostrclient_generic_router.get("/", response_class=HTMLResponse) @nostrclient_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, account: Account = Depends(check_admin)): async def index(request: Request, user: User = Depends(check_admin)):
user = await get_user_from_account(account)
if not user:
return HTMLResponse("No user found", status_code=404)
return nostr_renderer().TemplateResponse( return nostr_renderer().TemplateResponse(
"nostrclient/index.html", {"request": request, "user": user.json()} "nostrclient/index.html", {"request": request, "user": user.json()}
) )