Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ __pycache__
node_modules
.mypy_cache
.venv
CLAUDE.md
4 changes: 2 additions & 2 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "Nostr Client",
"name": "Nostr Proxy",
"short_description": "Nostr relay multiplexer",
"version": "1.1.0",
"tile": "/nostrclient/static/images/nostr-bitcoin.png",
"tile": "/nostrclient/static/images/nostr-proxy.png",
"contributors": ["calle", "motorina0", "dni"],
"min_lnbits_version": "1.4.0",
"images": [
Expand Down
2 changes: 1 addition & 1 deletion description.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
An always-on 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.
Other LNbits extensions like **Nostr Market** and **NWC Provider** use this extension to communicate on Nostr. You can also connect your own Nostr client to the WebSocket endpoint, which fans out your requests to all configured relays and aggregates the responses.

- **Simplified Client Configuration** - Connect to one endpoint instead of managing multiple relay connections
- **Always-On Connectivity** - Your LNbits instance maintains persistent connections to relays
Expand Down
1 change: 1 addition & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class TestMessageResponse(BaseModel):
class Config(BaseModel):
private_ws: bool = True
public_ws: bool = False
private_ws_endpoint: str | None = None


class UserConfig(BaseModel):
Expand Down
118 changes: 118 additions & 0 deletions static/images/generate_logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Generate the Nostr Proxy logo.
Requires: pip install Pillow
"""
import math

from PIL import Image, ImageDraw # type: ignore[import-not-found]

# Render at 4x size for antialiasing
scale = 4
size = 128 * scale
final_size = 128

dark_purple = (80, 40, 120)
light_purple = (140, 100, 180)
white = (255, 255, 255)
white_transparent = (255, 255, 255, 180)

margin = 4 * scale

swoosh_center = ((128 + 100) * scale, -90 * scale)
swoosh_radius = 220 * scale

# Create circular mask
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse([margin, margin, size - margin, size - margin], fill=255)

# Create background with swoosh
bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
bg_draw = ImageDraw.Draw(bg)
bg_draw.ellipse([margin, margin, size - margin, size - margin], fill=dark_purple)
bg_draw.ellipse(
[
swoosh_center[0] - swoosh_radius,
swoosh_center[1] - swoosh_radius,
swoosh_center[0] + swoosh_radius,
swoosh_center[1] + swoosh_radius,
],
fill=light_purple,
)

# Apply circular mask
final = Image.new("RGBA", (size, size), (0, 0, 0, 0))
final.paste(bg, mask=mask)
draw = ImageDraw.Draw(final)

center_x, center_y = size // 2, size // 2
radius = 44 * scale
angles = [-35, -12, 12, 35]
relay_positions = [
(
center_x + radius * math.cos(math.radians(a)),
center_y + radius * math.sin(math.radians(a)),
)
for a in angles
]

for x, y in relay_positions:
draw.line([(center_x, center_y), (x, y)], fill=white_transparent, width=2 * scale)

# Central circle (the multiplexer)
draw.ellipse(
[
center_x - 14 * scale,
center_y - 14 * scale,
center_x + 14 * scale,
center_y + 14 * scale,
],
fill=white,
)

# Bi-directional arrow
arrow_head_size = 8 * scale
left_tip = 10 * scale # leftmost point of left arrow
right_tip = center_x - 14 * scale # rightmost point (touching circle)

# Arrow shaft - between the two arrow heads (not extending into them)
shaft_left = left_tip + arrow_head_size
shaft_right = right_tip - arrow_head_size
draw.line(
[(shaft_left, center_y), (shaft_right, center_y)],
fill=white,
width=4 * scale,
)

# Right-pointing arrow head (going into circle) - tip touches circle
draw.polygon(
[
(right_tip, center_y),
(right_tip - arrow_head_size, center_y - 6 * scale),
(right_tip - arrow_head_size, center_y + 6 * scale),
],
fill=white,
)

# Left-pointing arrow head (coming out) - tip at left edge
draw.polygon(
[
(left_tip, center_y),
(left_tip + arrow_head_size, center_y - 6 * scale),
(left_tip + arrow_head_size, center_y + 6 * scale),
],
fill=white,
)

# Draw output circles on top
for x, y in relay_positions:
draw.ellipse(
[x - 7 * scale, y - 7 * scale, x + 7 * scale, y + 7 * scale], fill=white
)

# Downscale with LANCZOS for antialiasing
final = final.resize((final_size, final_size), Image.LANCZOS)

final.save("nostr-proxy.png")
print("Logo saved to nostr-proxy.png")
Binary file added static/images/nostr-proxy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 92 additions & 37 deletions templates/nostrclient/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -141,30 +141,6 @@ <h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<div class="text-weight-bold">
<q-btn
flat
dense
size="0.6rem"
class="q-px-none q-mx-none"
color="grey"
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"
><q-tooltip>Copy address</q-tooltip></q-btn
>
Your endpoint:
<q-badge
outline
class="q-ml-sm text-subtitle2"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</div>
</div>
</div>
</q-card-section>
<q-expansion-item
group="advanced"
icon="settings"
Expand Down Expand Up @@ -306,23 +282,94 @@ <h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Nostrclient Extension</h6>
<h6 class="text-subtitle1 q-my-none">Nostr Proxy</h6>
<p>
This extension is a always-on nostr client that other extensions can
use to send and receive events on nostr. Add multiple nostr relays to
connect to. The extension then opens a websocket for you to use at
An always-on relay multiplexer for your LNbits instance. Other
extensions like Nostr Market and NWC Provider use this to communicate
on Nostr. You can also connect your own Nostr client to the WebSocket
endpoint below.
</p>

<p>
<q-badge
outline
class="q-ml-sm text-subtitle2"
color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
<div class="q-mt-md">
<div v-if="config.data.private_ws_endpoint" class="q-mb-sm">
<div class="text-caption text-grey q-mb-xs">Private endpoint:</div>
<div class="row items-center no-wrap q-gutter-sm">
<q-input
:model-value="`wss://${host}/nostrclient/api/v1/${config.data.private_ws_endpoint}`"
outlined
dense
readonly
class="col"
:class="config.data.private_ws ? 'endpoint-enabled' : 'endpoint-disabled'"
>
<template v-slot:append>
<q-badge
:color="config.data.private_ws ? 'positive' : 'negative'"
:label="config.data.private_ws ? 'Enabled' : 'Disabled'"
/>
</template>
</q-input>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/${config.data.private_ws_endpoint}`)"
><q-tooltip>Copy</q-tooltip></q-btn
>
</div>
</div>

<div class="q-mb-sm">
<div class="text-caption text-grey q-mb-xs">Public endpoint:</div>
<div class="row items-center no-wrap q-gutter-sm">
<q-input
:model-value="`wss://${host}/nostrclient/api/v1/relay`"
outlined
dense
readonly
class="col"
:class="config.data.public_ws ? 'endpoint-enabled' : 'endpoint-disabled'"
>
<template v-slot:append>
<q-badge
:color="config.data.public_ws ? 'positive' : 'negative'"
:label="config.data.public_ws ? 'Enabled' : 'Disabled'"
/>
</template>
</q-input>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"
><q-tooltip>Copy</q-tooltip></q-btn
>
</div>
</div>

<q-banner
v-if="!config.data.public_ws && !config.data.private_ws"
class="bg-warning text-dark q-mt-sm"
dense
>
No endpoints enabled. Enable Public or Private WebSocket in
Settings.
</q-banner>
</div>

<p class="q-mt-md text-caption text-grey">
Only Admin users can manage this extension.
</p>
<p class="text-caption">
<a
href="https://github.com/lnbits/nostrclient/issues"
target="_blank"
class="text-grey"
style="text-decoration: none"
>
💡 Have feedback or found a bug? Let us know on GitHub
</a>
</p>
Only Admin users can manage this extension.
<q-card-section></q-card-section>
</q-card-section>
</q-card>
</div>
Expand Down Expand Up @@ -697,4 +744,12 @@ <h6 class="text-subtitle1 q-my-none">Nostrclient Extension</h6>
}
})
</script>
<style>
.endpoint-enabled.q-field--outlined .q-field__control:before {
border-color: #21ba45 !important;
}
.endpoint-disabled.q-field--outlined .q-field__control:before {
border-color: #c10015 !important;
}
</style>
{% endblock %}
8 changes: 7 additions & 1 deletion views_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

from fastapi import APIRouter, Depends, HTTPException, WebSocket
from lnbits.decorators import check_admin
from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash
from lnbits.helpers import (
decrypt_internal_message,
encrypt_internal_message,
urlsafe_short_hash,
)
from loguru import logger

from .crud import (
Expand Down Expand Up @@ -169,6 +173,8 @@ async def api_get_config() -> Config:
if not config:
config = await create_config(owner_id="admin")
assert config, "Failed to create config"
# Add private WebSocket endpoint for admin use
config.private_ws_endpoint = encrypt_internal_message("relay", urlsafe=True)
return config


Expand Down