Compare commits

...

10 commits

Author SHA1 Message Date
dni ⚡
35584a230f
chore: add uv, linting, fixes (#39)
Some checks failed
CI / lint (push) Has been cancelled
/ release (push) Has been cancelled
CI / tests (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* chore: add uv, linting, fixes
2025-10-30 10:43:27 +01:00
PatMulligan
15079c3e58
fix(nostrrelay): use schema-qualified table name in delete_events (#38) 2025-10-27 09:54:29 +02:00
PatMulligan
22df5868de
FEAT: Implement NIP-01 Addressable Events Support (#33)
* Implement NIP-16 parameterized replaceable events

Add support for parameterized replaceable events (kinds 30000-39999) to
properly
handle Nostr marketplace product and stall updates according to NIP-16
specification.

Changes:
- Add is_parameterized_replaceable_event property to NostrEvent
- Implement automatic deletion of previous versions when new
parameterized replaceable event is received
- Add 'd' tag filtering support to NostrFilter for parameterized
replacement logic
- Update SQL query generation to handle 'd' tag joins

Fixes issue where product updates would create duplicate entries instead
of
replacing previous versions, ensuring only the latest version remains
visible.

* Refactor event handling for addressable events

Renamed the property is_parameterized_replaceable_event to is_addressable_event in NostrEvent to align with NIP-01 specifications (previously NIP-16). Updated the client_connection.py to utilize the new property for extracting 'd' tag values for addressable replacement, ensuring proper event handling in the relay system.

* Refactor tag filtering logic in NostrFilter

Updated the tag filtering mechanism to ensure that the filter only fails if the specified tags ('e' and 'p') are not found. This change improves clarity and maintains functionality by allowing for more precise control over event filtering.

* update readme

* Fix addressable event deletion and SQL schema issues

- Fix Pydantic field alias usage for d tag filtering (use #d instead of
d)
- Remove nostrrelay schema prefixes from SQL table references
- Implement subquery approach for DELETE operations with JOINs
- Resolve SQLite DELETE syntax incompatibility with JOIN statements
- Ensure NIP-33 compliance: only delete events with matching d tag
values
2025-09-10 16:40:40 +03:00
PatMulligan
687d7b89c1
Fix REQ message handling to support multiple filter subscriptions (#34)
This fix addresses an issue where REQ messages with multiple filters
were being rejected by the relay. Notably: The nostrmarket extension's
"Refresh from Nostr" functionality sends a single REQ message containing
4 different filter subscriptions:
- Direct Messages (kinds: [4])
- Stalls (kinds: [30017])
- Products (kinds: [30018])
- Profile (kinds: [0])

Changes:
- Changed validation from `len(data) != 3` to `len(data) < 3` to allow
multiple filters
- Added loop to process all filters in a single REQ message (data[2:])
- Accumulate responses from all filters before returning

This ensures compatibility with clients that batch multiple subscription
filters in a single REQ message, which is a valid pattern according to
NIP-01.
2025-09-10 16:35:25 +03:00
21M4TW
5a1a400f45
-Two issues were dicovered in get_config_for_all_active_relays which led (#32)
to errors while loading relay configuration from the DB and that also
caused the loaded meta information to be invalid. Mandatory fields for
NostrRelay were not selected by the query, and a dictionary
representation of the meta object should not be returned as it causes
some members such as require_auth_filter and event_requires_auth to not
be accessible, leading to breaking exceptions.
2025-06-16 11:59:13 +03:00
Vlad Stan
3dc066fbd4
[fix] Ws api tests (#31) 2024-11-11 15:25:25 +02:00
dni ⚡
73054fd5ce
feat: update to v1.0.0 (#30) 2024-11-08 15:32:04 +02:00
Bitkarrot
2bdbbb274d
Update client_connection.py (#29)
omit return none, in ref to https://github.com/lnbits/nostrrelay/issues/27
2024-10-14 09:39:48 +03:00
dni ⚡
cc6752003a
feat: improve codequality and CI (#25)
* feat: improve codequality and CI
2024-08-30 13:20:23 +02:00
dni ⚡
28121184c3
fix: properly start/stop tasks (#22)
https://github.com/lnbits/nostrclient/pull/28
2024-06-26 10:29:04 +02:00
39 changed files with 3892 additions and 1414 deletions

29
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
tests:
runs-on: ubuntu-latest
needs: [lint]
steps:
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
PYTHONUNBUFFERED: 1
DEBUG: true
with:
verbose: true
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: uv run pytest
report-title: 'test'

View file

@ -1,10 +1,9 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
repo_name: '${{ github.event.repository.name }}'
tag: '${{ github.ref_name }}'
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: |
cd lnbits-extensions
git checkout -b $branch

6
.gitignore vendored
View file

@ -1 +1,5 @@
__pycache__
__pycache__
node_modules
.venv
.mypy_cache
data

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

49
Makefile Normal file
View file

@ -0,0 +1,49 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
uv run ./node_modules/.bin/prettier --write .
pyright:
uv run ./node_modules/.bin/pyright
mypy:
uv run mypy .
black:
uv run black .
ruff:
uv run ruff check . --fix
checkruff:
uv run ruff check .
checkprettier:
uv run ./node_modules/.bin/prettier --check .
checkblack:
uv run black --check .
checkeditorconfig:
editorconfig-checker
test:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
pre-commit:
uv run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -15,6 +15,10 @@
## Supported NIPs
- [x] **NIP-01**: Basic protocol flow
- [x] Regular Events
- [x] Replaceable Events (kinds 10000-19999)
- [x] Ephemeral Events (kinds 20000-29999)
- [x] Addressable Events (kinds 30000-39999)
- [x] **NIP-02**: Contact List and Petnames
- `kind: 3`: delete past contact lists as soon as the relay receives a new one
- [x] **NIP-04**: Encrypted Direct Message
@ -36,8 +40,8 @@
- not planned
- [x] **NIP-28** Public Chat
- `kind: 41`: handled similar to `kind 0` metadata events
- [ ] **NIP-33**: Parameterized Replaceable Events
- todo
- [x] **NIP-33**: Addressable Events (moved to NIP-01)
- ✅ Implemented as part of NIP-01 addressable events
- [ ] **NIP-40**: Expiration Timestamp
- todo
- [x] **NIP-42**: Authentication of clients to relays

View file

@ -1,15 +1,18 @@
import asyncio
from typing import List
from fastapi import APIRouter
from loguru import logger
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_nostrrelay")
from .client_manager import client_manager
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import nostrrelay_generic_router
from .views_api import nostrrelay_api_router
nostrrelay_ext: APIRouter = APIRouter(prefix="/nostrrelay", tags=["NostrRelay"])
nostrrelay_ext.include_router(nostrrelay_generic_router)
nostrrelay_ext.include_router(nostrrelay_api_router)
nostrrelay_static_files = [
{
@ -26,19 +29,31 @@ nostrrelay_redirect_paths = [
}
]
scheduled_tasks: List[asyncio.Task] = []
scheduled_tasks: list[asyncio.Task] = []
def nostrrelay_renderer():
return template_renderer(["nostrrelay/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
async def nostrrelay_stop():
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
try:
await client_manager.stop()
except Exception as ex:
logger.warning(ex)
def nostrrelay_start():
loop = asyncio.get_event_loop()
task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_nostrrelay", wait_for_paid_invoices)
scheduled_tasks.append(task)
__all__ = [
"db",
"nostrrelay_ext",
"nostrrelay_start",
"nostrrelay_stop",
]

3
client_manager.py Normal file
View file

@ -0,0 +1,3 @@
from .relay.client_manager import NostrClientManager
client_manager: NostrClientManager = NostrClientManager()

View file

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

431
crud.py
View file

@ -1,111 +1,71 @@
import json
from typing import List, Optional, Tuple
from . import db
from .models import NostrAccount
from lnbits.db import Database
from .models import NostrAccount, NostrEventTags
from .relay.event import NostrEvent
from .relay.filter import NostrFilter
from .relay.relay import NostrRelay, RelayPublicSpec, RelaySpec
from .relay.relay import NostrRelay, RelayPublicSpec
########################## RELAYS ####################
db = Database("ext_nostrrelay")
async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay:
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"
async def create_relay(relay: NostrRelay) -> NostrRelay:
await db.insert("nostrrelay.relays", relay)
return relay
async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay:
await db.execute(
"""
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,
),
async def update_relay(relay: NostrRelay) -> NostrRelay:
await db.update("nostrrelay.relays", relay, "WHERE user_id = :user_id AND id = :id")
return relay
async def get_relay(user_id: str, relay_id: str) -> NostrRelay | None:
return await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
{"user_id": user_id, "id": relay_id},
NostrRelay,
)
return r
async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]:
row = await db.fetchone(
"""SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""",
(
user_id,
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) -> NostrRelay | None:
"""Note: it does not require `user_id`. Can read any relay. Use it with care."""
row = await db.fetchone(
"""SELECT * FROM nostrrelay.relays WHERE id = ?""",
(relay_id,),
return await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE id = :id",
{"id": relay_id},
NostrRelay,
)
return NostrRelay.from_row(row) if row else None
async def get_relays(user_id: str) -> List[NostrRelay]:
rows = await db.fetchall(
"""SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""",
(user_id,),
async def get_relays(user_id: str) -> list[NostrRelay]:
return await db.fetchall(
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id ORDER BY id ASC",
{"user_id": user_id},
NostrRelay,
)
return [NostrRelay.from_row(row) for row in rows]
async def get_config_for_all_active_relays() -> dict:
rows = await db.fetchall(
"SELECT id, meta FROM nostrrelay.relays WHERE active = true",
relays = await db.fetchall(
"SELECT * FROM nostrrelay.relays WHERE active = true",
model=NostrRelay,
)
active_relay_configs = {}
for r in rows:
active_relay_configs[r["id"]] = RelaySpec(
**json.loads(r["meta"])
) # todo: from_json
for relay in relays:
active_relay_configs[relay.id] = relay.meta
return active_relay_configs
async def get_public_relay(relay_id: str) -> Optional[dict]:
row = await db.fetchone(
"""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)
async def get_public_relay(relay_id: str) -> dict | None:
relay = await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE id = :id",
{"id": relay_id},
NostrRelay,
)
if not row:
if not relay:
return None
relay = NostrRelay.from_row(row)
return {
**NostrRelay.info(),
"id": relay.id,
@ -113,142 +73,144 @@ async def get_public_relay(relay_id: str) -> Optional[dict]:
"description": relay.description,
"pubkey": relay.pubkey,
"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):
await db.execute(
"""DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""",
(
user_id,
relay_id,
),
"DELETE FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
{"user_id": user_id, "id": relay_id},
)
########################## EVENTS ####################
async def create_event(relay_id: str, e: NostrEvent, publisher: Optional[str]):
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,
),
)
async def create_event(event: NostrEvent):
event_ = await get_event(event.relay_id, event.id)
if event_:
return None
await db.insert("nostrrelay.events", event)
# todo: optimize with bulk insert
for tag in e.tags:
for tag in event.tags:
name, value, *rest = tag
extra = json.dumps(rest) if rest else None
await create_event_tags(relay_id, e.id, name, value, extra)
_tag = NostrEventTags(
relay_id=event.relay_id,
event_id=event.id,
name=name,
value=value,
extra=extra,
)
await create_event_tags(_tag)
async def get_events(
relay_id: str, filter: NostrFilter, include_tags=True
) -> List[NostrEvent]:
query, values = build_select_events_query(relay_id, filter)
relay_id: str, nostr_filter: NostrFilter, include_tags=True
) -> list[NostrEvent]:
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 = []
for row in rows:
event = NostrEvent.from_row(row)
# todo: check & enforce range
if nostr_filter.limit and nostr_filter.limit > 0:
query += f" LIMIT {nostr_filter.limit}"
events = await db.fetchall(query, values, NostrEvent)
for event in events:
if include_tags:
event.tags = await get_event_tags(relay_id, event.id)
events.append(event)
return events
async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]:
row = await db.fetchone(
"SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?",
(
relay_id,
id,
),
async def get_event(relay_id: str, event_id: str) -> NostrEvent | None:
event = await db.fetchone(
"SELECT * FROM nostrrelay.events WHERE relay_id = :relay_id AND id = :id",
{"relay_id": relay_id, "id": event_id},
NostrEvent,
)
if not row:
if not event:
return None
event = NostrEvent.from_row(row)
event.tags = await get_event_tags(relay_id, id)
event.tags = await get_event_tags(relay_id, event_id)
return event
async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> int:
"""Returns the storage space in bytes for all the events of a public key. Deleted events are also counted"""
row = await db.fetchone(
"SELECT SUM(size) as sum FROM nostrrelay.events WHERE relay_id = ? AND publisher = ? GROUP BY publisher",
(
relay_id,
publisher_pubkey,
),
"""
Returns the storage space in bytes for all the events of a public key.
Deleted events are also counted
"""
row: dict = await db.fetchone(
"""
SELECT SUM(size) as sum FROM nostrrelay.events
WHERE relay_id = :relay_id AND publisher = :publisher GROUP BY publisher
""",
{"relay_id": relay_id, "publisher": publisher_pubkey},
)
if not row:
return 0
return round(row["sum"])
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, so the data size should be small"""
query = """
SELECT id, size FROM nostrrelay.events
WHERE relay_id = ? AND pubkey = ?
ORDER BY created_at ASC LIMIT 10000
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,
so the data size should be small
"""
events = await db.fetchall(
"""
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 [(r["id"], r["size"]) for r in rows]
return [(event.id, event.size_bytes) for event in events]
async def mark_events_deleted(relay_id: str, filter: NostrFilter):
if filter.is_empty():
async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter):
if nostr_filter.is_empty():
return None
_, where, values = filter.to_sql_components(relay_id)
_, where, values = nostr_filter.to_sql_components(relay_id)
await db.execute(
f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""",
tuple(values),
f"UPDATE nostrrelay.events SET deleted=true WHERE {' AND '.join(where)}",
values,
)
async def delete_events(relay_id: str, filter: NostrFilter):
if filter.is_empty():
async def delete_events(relay_id: str, nostr_filter: NostrFilter):
if nostr_filter.is_empty():
return None
_, where, values = filter.to_sql_components(relay_id)
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}"""
await db.execute(query, tuple(values))
if inner_joins:
# Use subquery for DELETE operations with JOINs
subquery = f"""
SELECT nostrrelay.events.id FROM nostrrelay.events
{" ".join(inner_joins)}
WHERE {" AND ".join(where)}
"""
query = f"DELETE FROM nostrrelay.events WHERE id IN ({subquery})"
else:
# Simple DELETE without JOINs
query = f"DELETE FROM nostrrelay.events WHERE {' AND '.join(where)}"
await db.execute(query, values)
# todo: delete tags
# move to services
async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
prunable_events = await get_prunable_events(relay_id, pubkey)
prunable_event_ids = []
@ -265,138 +227,87 @@ async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
async def delete_all_events(relay_id: str):
query = "DELETE from nostrrelay.events WHERE relay_id = ?"
await db.execute(query, (relay_id,))
await db.execute(
"DELETE from nostrrelay.events WHERE relay_id = :id",
{"id": relay_id},
)
# todo: delete tags
async def create_event_tags(
relay_id: str,
event_id: str,
tag_name: str,
tag_value: str,
extra_values: Optional[str],
):
await db.execute(
async def create_event_tags(tag: NostrEventTags):
await db.insert("nostrrelay.event_tags", tag)
async def get_event_tags(relay_id: str, event_id: str) -> list[list[str]]:
_tags = await db.fetchall(
"""
INSERT INTO nostrrelay.event_tags (
relay_id,
event_id,
name,
value,
extra
)
VALUES (?, ?, ?, ?, ?)
SELECT * FROM nostrrelay.event_tags
WHERE relay_id = :relay_id and event_id = :event_id
""",
(relay_id, event_id, tag_name, tag_value, extra_values),
{"relay_id": relay_id, "event_id": event_id},
model=NostrEventTags,
)
async def get_event_tags(relay_id: str, event_id: str) -> List[List[str]]:
rows = await db.fetchall(
"SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?",
(relay_id, event_id),
)
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)
tags: list[list[str]] = []
for tag in _tags:
_tag = [tag.name, tag.value]
if tag.extra:
_tag += json.loads(tag.extra)
tags.append(_tag)
return tags
def build_select_events_query(relay_id: str, filter: NostrFilter):
inner_joins, where, values = filter.to_sql_components(relay_id)
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 filter.limit and filter.limit > 0:
query += f" LIMIT {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"
async def create_account(account: NostrAccount) -> NostrAccount:
await db.insert("nostrrelay.accounts", account)
return account
async def update_account(relay_id: str, a: NostrAccount) -> NostrAccount:
await db.execute(
"""
UPDATE nostrrelay.accounts
SET (sats, storage, paid_to_join, allowed, blocked) = (?, ?, ?, ?, ?)
WHERE relay_id = ? AND pubkey = ?
""",
(a.sats, a.storage, a.paid_to_join, a.allowed, a.blocked, relay_id, a.pubkey),
async def update_account(account: NostrAccount) -> NostrAccount:
await db.update(
"nostrrelay.accounts",
account,
"WHERE relay_id = :relay_id AND pubkey = :pubkey",
)
return a
return account
async def delete_account(relay_id: str, pubkey: str):
await db.execute(
"""
DELETE FROM nostrrelay.accounts
WHERE relay_id = ? AND pubkey = ?
WHERE relay_id = :id AND pubkey = :pubkey
""",
(relay_id, pubkey),
{"id": relay_id, "pubkey": pubkey},
)
async def get_account(
relay_id: str,
pubkey: str,
) -> Optional[NostrAccount]:
row = await db.fetchone(
"SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND pubkey = ?",
(relay_id, pubkey),
) -> NostrAccount | None:
return await db.fetchone(
"""
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(
relay_id: str,
allowed=True,
blocked=False,
) -> List[NostrAccount]:
) -> list[NostrAccount]:
if not allowed and not blocked:
return []
rows = await db.fetchall(
"SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND allowed = ? OR blocked = ?",
(relay_id, allowed, blocked),
return await db.fetchall(
"""
SELECT * FROM nostrrelay.accounts
WHERE relay_id = :id AND allowed = :allowed OR blocked = :blocked
""",
{"id": relay_id, "allowed": allowed, "blocked": blocked},
NostrAccount,
)
return [NostrAccount.from_row(row) for row in rows]

View file

@ -1,7 +1,8 @@
Create a Nostr relay in just 2 steps!
Optional settings include:
* Charging for storage
* Charging for joining
* Npub allow/ban list (for restricting access)
* Pruning and filtering
- Charging for storage
- Charging for joining
- Npub allow/ban list (for restricting access)
- Pruning and filtering

View file

@ -1,6 +1,3 @@
from sqlite3 import Row
from typing import Optional
from pydantic import BaseModel
@ -8,26 +5,27 @@ class BuyOrder(BaseModel):
action: str
relay_id: str
pubkey: str
units_to_buy = 0
units_to_buy: int = 0
def is_valid_action(self):
def is_valid_action(self) -> bool:
return self.action in ["join", "storage"]
class NostrPartialAccount(BaseModel):
relay_id: str
pubkey: str
allowed: Optional[bool]
blocked: Optional[bool]
allowed: bool | None = None
blocked: bool | None = None
class NostrAccount(BaseModel):
pubkey: str
allowed = False
blocked = False
sats = 0
storage = 0
paid_to_join = False
relay_id: str
sats: int = 0
storage: int = 0
paid_to_join: bool = False
allowed: bool = False
blocked: bool = False
@property
def can_join(self):
@ -36,8 +34,12 @@ class NostrAccount(BaseModel):
@classmethod
def null_account(cls) -> "NostrAccount":
return NostrAccount(pubkey="")
return NostrAccount(pubkey="", relay_id="")
@classmethod
def from_row(cls, row: Row) -> "NostrAccount":
return cls(**dict(row))
class NostrEventTags(BaseModel):
relay_id: str
event_id: str
name: str
value: str
extra: str | None = None

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "nostrrelay",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nostrrelay",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.369",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.369.tgz",
"integrity": "sha512-K0mQzVNSN5yq+joFK0JraOlhtL2HKrubCa+SnFznkLsnoZKbmq7M8UpSSDsJKPFfevkmqOKodgGzvt27C6RJAg==",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "nostrrelay",
"version": "1.0.0",
"description": "Nostrrelay",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

97
pyproject.toml Normal file
View file

@ -0,0 +1,97 @@
[project]
name = "nostrrelay"
version = "0.0.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrrelay" }
dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
[dependency-groups]
dev= [
"black",
"pytest-asyncio",
"pytest",
"mypy==1.17.1",
"pre-commit",
"ruff",
"pytest-md",
]
[tool.mypy]
plugins = ["pydantic.mypy"]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[[tool.mypy.overrides]]
module = [
"secp256k1.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
exclude = [
"boltz_client"
]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
ignore = ["UP007"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "views_api.py" = ["F401"]
[tool.ruff.lint.mccabe]
max-complexity = 11
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

View file

@ -1,11 +1,11 @@
import json
import time
from typing import Any, Awaitable, Callable, List, Optional
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import WebSocket
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from ..crud import (
NostrAccount,
@ -26,17 +26,17 @@ class NostrClientConnection:
def __init__(self, relay_id: str, websocket: WebSocket):
self.websocket = websocket
self.relay_id = relay_id
self.filters: List[NostrFilter] = []
self.auth_pubkey: Optional[str] = None # set if authenticated
self._auth_challenge: Optional[str] = None
self.filters: list[NostrFilter] = []
self.auth_pubkey: str | None = None # set if authenticated
self._auth_challenge: str | None = None
self._auth_challenge_created_at = 0
self.event_validator = EventValidator(self.relay_id)
self.broadcast_event: Optional[
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]
] = None
self.get_client_config: Optional[Callable[[], RelaySpec]] = None
self.broadcast_event: (
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] | None
) = None
self.get_client_config: Callable[[], RelaySpec] | None = None
async def start(self):
await self.websocket.accept()
@ -51,30 +51,30 @@ class NostrClientConnection:
except Exception as e:
logger.warning(e)
async def stop(self, reason: Optional[str]):
async def stop(self, reason: str | None):
message = reason if reason else "Server closed webocket"
try:
await self._send_msg(["NOTICE", message])
except:
except Exception:
pass
try:
await self.websocket.close(reason=reason)
except:
except Exception:
pass
def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable):
setattr(self, "broadcast_event", broadcast_event)
setattr(self, "get_client_config", get_client_config)
setattr(self.event_validator, "get_client_config", get_client_config)
self.broadcast_event = broadcast_event
self.get_client_config = get_client_config
self.event_validator.get_client_config = get_client_config
async def notify_event(self, event: NostrEvent) -> bool:
if self._is_direct_message_for_other(event):
return False
for filter in self.filters:
if filter.matches(event):
resp = event.serialize_response(filter.subscription_id)
for nostr_filter in self.filters:
if nostr_filter.matches(event):
resp = event.serialize_response(nostr_filter.subscription_id)
await self._send_msg(resp)
return True
return False
@ -82,7 +82,8 @@ class NostrClientConnection:
def _is_direct_message_for_other(self, event: NostrEvent) -> bool:
"""
Direct messages are not inteded to be boradcast (even if encrypted).
If the server requires AUTH for kind '4' then direct message will be sent only to the intended client.
If the server requires AUTH for kind '4' then direct message will be
sent only to the intended client.
"""
if not event.is_direct_message:
return False
@ -98,18 +99,34 @@ class NostrClientConnection:
if self.broadcast_event:
await self.broadcast_event(self, e)
async def _handle_message(self, data: List) -> List:
async def _handle_message(self, data: list) -> list:
if len(data) < 2:
return []
message_type = data[0]
if message_type == NostrEventType.EVENT:
await self._handle_event(NostrEvent.parse_obj(data[1]))
event_dict = {
"relay_id": self.relay_id,
"publisher": data[1]["pubkey"],
**data[1],
}
event = NostrEvent(**event_dict)
await self._handle_event(event)
return []
if message_type == NostrEventType.REQ:
if len(data) != 3:
if len(data) < 3:
return []
return await self._handle_request(data[1], NostrFilter.parse_obj(data[2]))
subscription_id = data[1]
# Handle multiple filters in REQ message
responses = []
for filter_data in data[2:]:
response = await self._handle_request(
subscription_id, NostrFilter.parse_obj(filter_data)
)
responses.extend(response)
return responses
if message_type == NostrEventType.CLOSE:
self._handle_close(data[1])
if message_type == NostrEventType.AUTH:
@ -119,7 +136,7 @@ class NostrClientConnection:
async def _handle_event(self, e: NostrEvent):
logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']")
resp_nip20: List[Any] = ["OK", e.id]
resp_nip20: list[Any] = ["OK", e.id]
if e.is_auth_response_event:
valid, message = self.event_validator.validate_auth_event(
@ -130,13 +147,12 @@ class NostrClientConnection:
await self._send_msg(resp_nip20)
return None
self.auth_pubkey = e.pubkey
return None
if not self.auth_pubkey and self.config.event_requires_auth(e.kind):
await self._send_msg(["AUTH", self._current_auth_challenge()])
resp_nip20 += [
False,
f"restricted: Relay requires authentication for events of kind '{e.kind}'",
f"Relay requires authentication for events of kind '{e.kind}'",
]
await self._send_msg(resp_nip20)
return None
@ -147,15 +163,27 @@ class NostrClientConnection:
resp_nip20 += [valid, message]
await self._send_msg(resp_nip20)
return None
try:
if e.is_replaceable_event:
await delete_events(
self.relay_id,
NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at),
)
if e.is_addressable_event:
# Extract 'd' tag value for addressable replacement (NIP-01)
d_tag_value = next((t[1] for t in e.tags if t[0] == "d"), None)
if d_tag_value:
deletion_filter = NostrFilter(
kinds=[e.kind],
authors=[e.pubkey],
**{"#d": [d_tag_value]}, # type: ignore
until=e.created_at,
)
await delete_events(self.relay_id, deletion_filter)
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)
if e.is_delete_event:
@ -166,7 +194,7 @@ class NostrClientConnection:
event = await get_event(self.relay_id, e.id)
# todo: handle NIP20 in detail
message = "error: failed to create event"
resp_nip20 += [event != None, message]
resp_nip20 += [event is not None, message]
await self._send_msg(resp_nip20)
@ -176,18 +204,20 @@ class NostrClientConnection:
raise Exception("Client not ready!")
return self.get_client_config()
async def _send_msg(self, data: List):
async def _send_msg(self, data: list):
await self.websocket.send_text(json.dumps(data))
async def _handle_delete_event(self, event: NostrEvent):
# NIP 09
filter = NostrFilter(authors=[event.pubkey])
filter.ids = [t[1] for t in event.tags if t[0] == "e"]
events_to_delete = await get_events(self.relay_id, filter, False)
nostr_filter = NostrFilter(authors=[event.pubkey])
nostr_filter.ids = [t[1] for t in event.tags if t[0] == "e"]
events_to_delete = await get_events(self.relay_id, nostr_filter, False)
ids = [e.id for e in events_to_delete if not e.is_delete_event]
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids))
async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List:
async def _handle_request(
self, subscription_id: str, nostr_filter: NostrFilter
) -> list:
if self.config.require_auth_filter:
if not self.auth_pubkey:
return [["AUTH", self._current_auth_challenge()]]
@ -199,26 +229,30 @@ class NostrClientConnection:
return [
[
"NOTICE",
f"Public key '{self.auth_pubkey}' is not allowed in relay '{self.relay_id}'!",
(
f"Public key '{self.auth_pubkey}' is not allowed "
f"in relay '{self.relay_id}'!"
),
]
]
if not account.can_join and not self.config.is_free_to_join:
return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]]
filter.subscription_id = subscription_id
nostr_filter.subscription_id = subscription_id
self._remove_filter(subscription_id)
if self._can_add_filter():
max_filters = self.config.max_client_filters
return [
[
"NOTICE",
f"Maximum number of filters ({self.config.max_client_filters}) exceeded.",
f"Maximum number of filters ({max_filters}) exceeded.",
]
]
filter.enforce_limit(self.config.limit_per_filter)
self.filters.append(filter)
events = await get_events(self.relay_id, filter)
nostr_filter.enforce_limit(self.config.limit_per_filter)
self.filters.append(nostr_filter)
events = await get_events(self.relay_id, nostr_filter)
events = [e for e in events if not self._is_direct_message_for_other(e)]
serialized_events = [
event.serialize_response(subscription_id) for event in events

View file

@ -1,5 +1,3 @@
from typing import List
from ..crud import get_config_for_all_active_relays
from .client_connection import NostrClientConnection
from .event import NostrEvent
@ -47,7 +45,7 @@ class NostrClientManager:
def get_relay_config(self, relay_id: str) -> RelaySpec:
return self._active_relays[relay_id]
def clients(self, relay_id: str) -> List[NostrClientConnection]:
def clients(self, relay_id: str) -> list[NostrClientConnection]:
if relay_id not in self._clients:
self._clients[relay_id] = []
return self._clients[relay_id]
@ -71,5 +69,5 @@ class NostrClientManager:
def get_client_config() -> RelaySpec:
return self.get_relay_config(client.relay_id)
setattr(client, "get_client_config", get_client_config)
client.get_client_config = get_client_config
client.init_callbacks(self.broadcast_event, get_client_config)

View file

@ -1,10 +1,8 @@
import hashlib
import json
from enum import Enum
from sqlite3 import Row
from typing import List
from pydantic import BaseModel
from pydantic import BaseModel, Field
from secp256k1 import PublicKey
@ -17,14 +15,22 @@ class NostrEventType(str, Enum):
class NostrEvent(BaseModel):
id: str
relay_id: str
publisher: str
pubkey: str
created_at: int
kind: int
tags: List[List[str]] = []
tags: list[list[str]] = Field(default=[], no_database=True)
content: str = ""
sig: str
def serialize(self) -> List:
def nostr_dict(self) -> dict:
_nostr_dict = dict(self)
_nostr_dict.pop("relay_id")
_nostr_dict.pop("publisher")
return _nostr_dict
def serialize(self) -> list:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str:
@ -34,12 +40,11 @@ class NostrEvent(BaseModel):
@property
def event_id(self) -> str:
data = self.serialize_json()
id = hashlib.sha256(data.encode()).hexdigest()
return id
return hashlib.sha256(data.encode()).hexdigest()
@property
def size_bytes(self) -> int:
s = json.dumps(dict(self), separators=(",", ":"), ensure_ascii=False)
s = json.dumps(self.nostr_dict(), separators=(",", ":"), ensure_ascii=False)
return len(s.encode())
@property
@ -66,6 +71,10 @@ class NostrEvent(BaseModel):
def is_ephemeral_event(self) -> bool:
return self.kind >= 20000 and self.kind < 30000
@property
def is_addressable_event(self) -> bool:
return self.kind >= 30000 and self.kind < 40000
def check_signature(self):
event_id = self.event_id
if self.id != event_id:
@ -74,10 +83,10 @@ class NostrEvent(BaseModel):
)
try:
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
except Exception:
except Exception as exc:
raise ValueError(
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
)
) from exc
valid_signature = pub_key.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
@ -86,9 +95,9 @@ class NostrEvent(BaseModel):
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
def serialize_response(self, subscription_id):
return [NostrEventType.EVENT, subscription_id, dict(self)]
return [NostrEventType.EVENT, subscription_id, self.nostr_dict()]
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]
def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
@ -96,7 +105,3 @@ class NostrEvent(BaseModel):
def is_direct_message_for_pubkey(self, pubkey: str) -> bool:
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,5 +1,5 @@
import time
from typing import Callable, Optional, Tuple
from collections.abc import Callable
from ..crud import get_account, get_storage_for_public_key, prune_old_events
from ..helpers import extract_domain
@ -15,11 +15,11 @@ class EventValidator:
self._last_event_timestamp = 0 # in hours
self._event_count_per_timestamp = 0
self.get_client_config: Optional[Callable[[], RelaySpec]] = None
self.get_client_config: Callable[[], RelaySpec] | None = None
async def validate_write(
self, e: NostrEvent, publisher_pubkey: str
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
valid, message = self._validate_event(e)
if not valid:
return (valid, message)
@ -34,8 +34,8 @@ class EventValidator:
return True, ""
def validate_auth_event(
self, e: NostrEvent, auth_challenge: Optional[str]
) -> Tuple[bool, str]:
self, e: NostrEvent, auth_challenge: str | None
) -> tuple[bool, str]:
valid, message = self._validate_event(e)
if not valid:
return (valid, message)
@ -59,9 +59,9 @@ class EventValidator:
raise Exception("EventValidator not ready!")
return self.get_client_config()
def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]:
def _validate_event(self, e: NostrEvent) -> tuple[bool, str]:
if self._exceeded_max_events_per_hour():
return False, f"Exceeded max events per hour limit'!"
return False, "Exceeded max events per hour limit'!"
try:
e.check_signature()
@ -76,7 +76,7 @@ class EventValidator:
async def _validate_storage(
self, pubkey: str, event_size_bytes: int
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
if self.config.is_read_only_relay:
return False, "Cannot write event, relay is read-only"
@ -101,7 +101,7 @@ class EventValidator:
if self.config.full_storage_action == "block":
return (
False,
f"Cannot write event, no more storage available for public key: '{pubkey}'",
f"Cannot write event, no storage available for public key: '{pubkey}'",
)
if event_size_bytes > total_available_storage:
@ -124,7 +124,7 @@ class EventValidator:
return self._event_count_per_timestamp > self.config.max_events_per_hour
def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]:
def _created_at_in_range(self, created_at: int) -> tuple[bool, str]:
current_time = round(time.time())
if self.config.created_at_in_past != 0:
if created_at < (current_time - self.config.created_at_in_past):

View file

@ -1,21 +1,19 @@
from typing import Any, List, Optional, Tuple
from pydantic import BaseModel, Field
from .event import NostrEvent
class NostrFilter(BaseModel):
subscription_id: Optional[str]
ids: List[str] = []
authors: List[str] = []
kinds: List[int] = []
e: List[str] = Field([], alias="#e")
p: List[str] = Field([], alias="#p")
since: Optional[int]
until: Optional[int]
limit: Optional[int]
e: list[str] = Field(default=[], alias="#e")
p: list[str] = Field(default=[], alias="#p")
d: list[str] = Field(default=[], alias="#d")
ids: list[str] = []
authors: list[str] = []
kinds: list[int] = []
subscription_id: str | None = None
since: int | None = None
until: int | None = None
limit: int | None = None
def matches(self, e: NostrEvent) -> bool:
# todo: starts with
@ -31,9 +29,12 @@ class NostrFilter(BaseModel):
if self.until and self.until > 0 and e.created_at > self.until:
return False
found_e_tag = self.tag_in_list(e.tags, "e")
found_p_tag = self.tag_in_list(e.tags, "p")
if not found_e_tag or not found_p_tag:
# Check tag filters - only fail if filter is specified and no match found
if not self.tag_in_list(e.tags, "e"):
return False
if not self.tag_in_list(e.tags, "p"):
return False
if not self.tag_in_list(e.tags, "d"):
return False
return True
@ -67,50 +68,56 @@ class NostrFilter(BaseModel):
if not self.limit or self.limit > limit:
self.limit = limit
def to_sql_components(
self, relay_id: str
) -> Tuple[List[str], List[str], List[Any]]:
inner_joins: List[str] = []
where = ["deleted=false", "nostrrelay.events.relay_id = ?"]
values: List[Any] = [relay_id]
def to_sql_components(self, relay_id: str) -> tuple[list[str], list[str], dict]:
inner_joins: list[str] = []
where = ["deleted=false", "nostrrelay.events.relay_id = :relay_id"]
values: dict = {"relay_id": relay_id}
if len(self.e):
values += self.e
e_s = ",".join(["?"] * len(self.e))
e_s = ",".join([f"'{e}'" for e in self.e])
inner_joins.append(
"INNER JOIN nostrrelay.event_tags e_tags ON nostrrelay.events.id = e_tags.event_id"
"INNER JOIN nostrrelay.event_tags e_tags "
"ON nostrrelay.events.id = e_tags.event_id"
)
where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')")
if len(self.p):
values += self.p
p_s = ",".join(["?"] * len(self.p))
p_s = ",".join([f"'{p}'" for p in self.p])
inner_joins.append(
"INNER JOIN nostrrelay.event_tags p_tags ON nostrrelay.events.id = p_tags.event_id"
"INNER JOIN nostrrelay.event_tags p_tags "
"ON nostrrelay.events.id = p_tags.event_id"
)
where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'")
if len(self.d):
d_s = ",".join([f"'{d}'" for d in self.d])
d_join = (
"INNER JOIN nostrrelay.event_tags d_tags "
"ON nostrrelay.events.id = d_tags.event_id"
)
d_where = f" d_tags.value in ({d_s}) AND d_tags.name = 'd'"
inner_joins.append(d_join)
where.append(d_where)
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})")
values += self.ids
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})")
values += self.authors
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})")
values += self.kinds
if self.since:
where.append("created_at >= ?")
values += [self.since]
where.append("created_at >= :since")
values["since"] = self.since
if self.until:
where.append("created_at < ?")
values += [self.until]
where.append("created_at < :until")
values["until"] = self.until
return inner_joins, where, values

View file

@ -1,7 +1,3 @@
import json
from sqlite3 import Row
from typing import Optional
from pydantic import BaseModel, Field
@ -11,22 +7,22 @@ class Spec(BaseModel):
class FilterSpec(Spec):
max_client_filters = Field(0, alias="maxClientFilters")
limit_per_filter = Field(1000, alias="limitPerFilter")
max_client_filters: int = Field(default=0, alias="maxClientFilters")
limit_per_filter: int = Field(default=1000, alias="limitPerFilter")
class EventSpec(Spec):
max_events_per_hour = Field(0, alias="maxEventsPerHour")
max_events_per_hour: int = Field(default=0, alias="maxEventsPerHour")
created_at_days_past = Field(0, alias="createdAtDaysPast")
created_at_hours_past = Field(0, alias="createdAtHoursPast")
created_at_minutes_past = Field(0, alias="createdAtMinutesPast")
created_at_seconds_past = Field(0, alias="createdAtSecondsPast")
created_at_days_past: int = Field(default=0, alias="createdAtDaysPast")
created_at_hours_past: int = Field(default=0, alias="createdAtHoursPast")
created_at_minutes_past: int = Field(default=0, alias="createdAtMinutesPast")
created_at_seconds_past: int = Field(default=0, alias="createdAtSecondsPast")
created_at_days_future = Field(0, alias="createdAtDaysFuture")
created_at_hours_future = Field(0, alias="createdAtHoursFuture")
created_at_minutes_future = Field(0, alias="createdAtMinutesFuture")
created_at_seconds_future = Field(0, alias="createdAtSecondsFuture")
created_at_days_future: int = Field(default=0, alias="createdAtDaysFuture")
created_at_hours_future: int = Field(default=0, alias="createdAtHoursFuture")
created_at_minutes_future: int = Field(default=0, alias="createdAtMinutesFuture")
created_at_seconds_future: int = Field(default=0, alias="createdAtSecondsFuture")
@property
def created_at_in_past(self) -> int:
@ -48,9 +44,9 @@ class EventSpec(Spec):
class StorageSpec(Spec):
free_storage_value = Field(1, alias="freeStorageValue")
free_storage_unit = Field("MB", alias="freeStorageUnit")
full_storage_action = Field("prune", alias="fullStorageAction")
free_storage_value: int = Field(default=1, alias="freeStorageValue")
free_storage_unit: str = Field(default="MB", alias="freeStorageUnit")
full_storage_action: str = Field(default="prune", alias="fullStorageAction")
@property
def free_storage_bytes_value(self):
@ -61,10 +57,10 @@ class StorageSpec(Spec):
class AuthSpec(Spec):
require_auth_events = Field(False, alias="requireAuthEvents")
skiped_auth_events = Field([], alias="skipedAuthEvents")
forced_auth_events = Field([], alias="forcedAuthEvents")
require_auth_filter = Field(False, alias="requireAuthFilter")
require_auth_events: bool = Field(default=False, alias="requireAuthEvents")
skiped_auth_events: list = Field(default=[], alias="skipedAuthEvents")
forced_auth_events: list = Field(default=[], alias="forcedAuthEvents")
require_auth_filter: bool = Field(default=False, alias="requireAuthFilter")
def event_requires_auth(self, kind: int) -> bool:
if self.require_auth_events:
@ -73,11 +69,11 @@ class AuthSpec(Spec):
class PaymentSpec(Spec):
is_paid_relay = Field(False, alias="isPaidRelay")
cost_to_join = Field(0, alias="costToJoin")
is_paid_relay: bool = Field(default=False, alias="isPaidRelay")
cost_to_join: int = Field(default=0, alias="costToJoin")
storage_cost_value = Field(0, alias="storageCostValue")
storage_cost_unit = Field("MB", alias="storageCostUnit")
storage_cost_value: int = Field(default=0, alias="storageCostValue")
storage_cost_unit: str = Field(default="MB", alias="storageCostUnit")
@property
def is_free_to_join(self):
@ -85,7 +81,7 @@ class PaymentSpec(Spec):
class WalletSpec(Spec):
wallet = Field("")
wallet: str = Field(default="")
class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
@ -93,7 +89,7 @@ class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
@property
def is_read_only_relay(self):
self.free_storage_value == 0 and not self.is_paid_relay
return self.free_storage_value == 0 and not self.is_paid_relay
class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
@ -102,23 +98,17 @@ class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
class NostrRelay(BaseModel):
id: str
user_id: str | None = None
name: str
description: Optional[str]
pubkey: Optional[str]
contact: Optional[str]
description: str | None = None
pubkey: str | None = None
contact: str | None = None
active: bool = False
config: "RelaySpec" = RelaySpec()
meta: RelaySpec = RelaySpec()
@property
def is_free_to_join(self):
return not self.config.is_paid_relay or self.config.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
return not self.meta.is_paid_relay or self.meta.cost_to_join == 0
@classmethod
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 () => {
Vue.component(VueQrcode.name, VueQrcode)
await relayDetails('static/components/relay-details/relay-details.html')
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
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 {
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
filter: '',
relayLinks: [],
formDialogRelay: {
show: false,
data: {
id: '',
name: '',
description: '',
@ -83,99 +16,158 @@ const relays = async () => {
}
},
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)
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
}
},
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'
)
}
}
},
methods: {
getDefaultRelayData: function () {
return {
id: '',
name: '',
description: '',
pubkey: '',
contact: ''
}
},
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
}
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

@ -1,12 +1,10 @@
import asyncio
import json
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services import websocket_updater
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .crud import create_account, get_account, update_account
from .models import NostrAccount
@ -14,7 +12,7 @@ from .models import NostrAccount
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
register_invoice_listener(invoice_queue, "ext_nostrrelay")
while True:
payment = await invoice_queue.get()
@ -27,75 +25,75 @@ async def on_invoice_paid(payment: Payment):
relay_id = payment.extra.get("relay_id")
pubkey = payment.extra.get("pubkey")
hash = payment.payment_hash
payment_hash = payment.payment_hash
if not relay_id or not pubkey:
message = f"Invoice extra data missing for 'relay_id' and 'pubkey'. Payment hash: {hash}"
message = (
"Invoice extra data missing for 'relay_id' and 'pubkey'. "
f"Payment hash: {payment_hash}"
)
logger.warning(message)
await websocket_updater(hash, json.dumps({"success": False, "message": message}))
await websocket_updater(
payment_hash, json.dumps({"success": False, "message": message})
)
return
action = payment.extra.get("action")
if action == "join":
await invoice_paid_to_join(relay_id, pubkey, payment.amount)
await websocket_updater(hash, json.dumps({"success": True}))
await websocket_updater(payment_hash, json.dumps({"success": True}))
return
if action == "storage":
storage_to_buy = payment.extra.get("storage_to_buy")
if not storage_to_buy:
message = (
f"Invoice extra data missing for 'storage_to_buy'. Payment hash: {hash}"
"Invoice extra data missing for 'storage_to_buy'. "
f"Payment hash: {payment_hash}"
)
logger.warning(message)
return
await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount)
await websocket_updater(hash, json.dumps({"success": True}))
await websocket_updater(payment_hash, json.dumps({"success": True}))
return
await websocket_updater(
hash, json.dumps({"success": False, "message": f"Bad action name: '{action}'"})
payment_hash,
json.dumps({"success": False, "message": f"Bad action name: '{action}'"}),
)
async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int):
try:
account = await get_account(relay_id, pubkey)
if not account:
await create_account(
relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True, sats=amount)
)
return
account = await get_account(relay_id, pubkey)
if not account:
account = NostrAccount(
relay_id=relay_id, pubkey=pubkey, paid_to_join=True, sats=amount
)
await create_account(account)
return
if account.blocked or account.paid_to_join:
return
if account.blocked or account.paid_to_join:
return
account.paid_to_join = True
account.sats += amount
await update_account(relay_id, account)
except Exception as ex:
logger.warning(ex)
account.paid_to_join = True
account.sats += amount
await update_account(account)
async def invoice_paid_for_storage(
relay_id: str, pubkey: str, storage_to_buy: int, amount: int
):
try:
account = await get_account(relay_id, pubkey)
if not account:
await create_account(
relay_id,
NostrAccount(pubkey=pubkey, storage=storage_to_buy, sats=amount),
)
return
account = await get_account(relay_id, pubkey)
if not account:
account = NostrAccount(
relay_id=relay_id, pubkey=pubkey, storage=storage_to_buy, sats=amount
)
await create_account(account)
return
if account.blocked:
return
if account.blocked:
return
account.storage = storage_to_buy
account.sats += amount
await update_account(relay_id, account)
except Exception as ex:
logger.warning(ex)
account.storage = storage_to_buy
account.sats += amount
await update_account(account)

View file

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

View file

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

View file

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

48
tests/conftest.py Normal file
View file

@ -0,0 +1,48 @@
import asyncio
import inspect
import pytest
import pytest_asyncio
from lnbits.db import Database
from loguru import logger
from pydantic import BaseModel
from .. import migrations
from ..relay.event import NostrEvent
from .helpers import get_fixtures
class EventFixture(BaseModel):
name: str
exception: str | None
data: NostrEvent
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session", autouse=True)
async def migrate_db():
print("#### 999")
db = Database("ext_nostrrelay")
for key, migrate in inspect.getmembers(migrations, inspect.isfunction):
print("### 1000")
logger.info(f"Running migration '{key}'.")
await migrate(db)
return db
@pytest.fixture(scope="session")
def valid_events(migrate_db) -> list[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["valid"]]
@pytest.fixture(scope="session")
def invalid_events(migrate_db) -> list[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["invalid"]]

View file

@ -4,6 +4,8 @@
"name": "kind 0, metadata",
"data": {
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
"relay_id": "r1",
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"created_at": 1675242172,
"kind": 0,
@ -19,6 +21,8 @@
"content": "i126",
"tags": [],
"created_at": 1675239988,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
@ -42,6 +46,8 @@
]
],
"created_at": 1675240147,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
@ -51,6 +57,8 @@
"name": "kind 3, contact list",
"data": {
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
"relay_id": "r1",
"publisher": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
"created_at": 1675095502,
"kind": 3,
@ -68,6 +76,8 @@
"name": "kind 3, relays",
"data": {
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"created_at": 1675175242,
"kind": 3,
@ -105,6 +115,8 @@
]
],
"created_at": 1675240247,
"relay_id": "r1",
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
@ -122,6 +134,8 @@
]
],
"created_at": 1675241034,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
@ -145,6 +159,8 @@
],
"content": "#[0]",
"created_at": 1675240471,
"relay_id": "r1",
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
@ -166,6 +182,8 @@
]
],
"created_at": 1675240377,
"relay_id": "r1",
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
@ -178,6 +196,8 @@
"tags": [["d", "chats/null/lastOpened"]],
"content": "1675242945",
"created_at": 1675242945,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
@ -190,6 +210,8 @@
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
"data": {
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
"relay_id": "r1",
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"created_at": 1675242172,
"kind": 0,
@ -206,6 +228,8 @@
"content": "i126",
"tags": [],
"created_at": 1675239988,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa"

View file

@ -1,6 +1,6 @@
import json
FIXTURES_PATH = "tests/extensions/nostrrelay/fixture"
FIXTURES_PATH = "./tests/fixture"
def get_fixtures(file):

View file

@ -1,19 +1,17 @@
import asyncio
from json import dumps, loads
from typing import Optional
import pytest
from fastapi import WebSocket
from loguru import logger
from lnbits.extensions.nostrrelay.relay.client_connection import ( # type: ignore
from ..relay.client_connection import (
NostrClientConnection,
)
from lnbits.extensions.nostrrelay.relay.client_manager import ( # type: ignore
from ..relay.client_manager import (
NostrClientManager,
)
from lnbits.extensions.nostrrelay.relay.relay import RelaySpec # type: ignore
from ..relay.relay import RelaySpec
from .helpers import get_fixtures
fixtures = get_fixtures("clients")
@ -26,10 +24,10 @@ RELAY_ID = "relay_01"
class MockWebSocket(WebSocket):
def __init__(self):
self.sent_messages = []
self.fake_wire: asyncio.Queue[str] = asyncio.Queue(0)
self.fake_wire = asyncio.Queue(0)
pass
async def accept(self):
async def accept(self, *_, **__):
await asyncio.sleep(0.1)
async def receive_text(self) -> str:
@ -42,8 +40,8 @@ class MockWebSocket(WebSocket):
async def wire_mock_data(self, data: dict):
await self.fake_wire.put(dumps(data))
async def close(self, code: int = 1000, reason: Optional[str] = None) -> None:
logger.info(reason)
async def close(self, code: int = 1000, reason: str | None = None) -> None:
logger.info(f"{code}: {reason}")
@pytest.mark.asyncio
@ -71,6 +69,9 @@ async def test_alice_and_bob():
await alice_deletes_post01__bob_is_notified(ws_alice, ws_bob)
tasks = []
async def init_clients():
client_manager = NostrClientManager()
await client_manager.enable_relay(RELAY_ID, RelaySpec())
@ -78,12 +79,15 @@ async def init_clients():
ws_alice = MockWebSocket()
client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice)
await client_manager.add_client(client_alice)
asyncio.create_task(client_alice.start())
task1 = asyncio.create_task(client_alice.start())
tasks.append(task1)
ws_bob = MockWebSocket()
client_bob = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_bob)
await client_manager.add_client(client_bob)
asyncio.create_task(client_bob.start())
task2 = asyncio.create_task(client_bob.start())
tasks.append(task2)
return ws_alice, ws_bob
@ -105,9 +109,6 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket):
assert ws_alice.sent_messages[1] == dumps(
alice["post01_response_ok"]
), "Alice: Wrong confirmation for post01"
assert ws_alice.sent_messages[2] == dumps(
alice["post01_response_duplicate"]
), "Alice: Expected failure for double posting"
assert ws_alice.sent_messages[3] == dumps(
alice["meta_update_response"]
), "Alice: Expected confirmation for meta update"
@ -152,9 +153,6 @@ async def bob_wires_contact_list(ws_alice: MockWebSocket, ws_bob: MockWebSocket)
await ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"])
await asyncio.sleep(0.1)
print("### ws_alice.sent_message", ws_alice.sent_messages)
print("### ws_bob.sent_message", ws_bob.sent_messages)
assert (
len(ws_bob.sent_messages) == 2
), "Bob: Expected 1 confirmation for create contact list"

View file

@ -1,42 +1,21 @@
import json
from typing import List, Optional
import pytest
from loguru import logger
from pydantic import BaseModel
from lnbits.extensions.nostrrelay.crud import ( # type: ignore
from ..crud import (
create_event,
get_event,
get_events,
)
from lnbits.extensions.nostrrelay.relay.event import NostrEvent # type: ignore
from lnbits.extensions.nostrrelay.relay.filter import NostrFilter # type: ignore
from .helpers import get_fixtures
from ..relay.event import NostrEvent
from ..relay.filter import NostrFilter
from .conftest import EventFixture
RELAY_ID = "r1"
class EventFixture(BaseModel):
name: str
exception: Optional[str]
data: NostrEvent
@pytest.fixture
def valid_events() -> List[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["valid"]]
@pytest.fixture
def invalid_events() -> List[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["invalid"]]
def test_valid_event_id_and_signature(valid_events: List[EventFixture]):
def test_valid_event_id_and_signature(valid_events: list[EventFixture]):
for f in valid_events:
try:
f.data.check_signature()
@ -45,14 +24,14 @@ def test_valid_event_id_and_signature(valid_events: List[EventFixture]):
raise e
def test_invalid_event_id_and_signature(invalid_events: List[EventFixture]):
def test_invalid_event_id_and_signature(invalid_events: list[EventFixture]):
for f in invalid_events:
with pytest.raises(ValueError, match=f.exception):
f.data.check_signature()
@pytest.mark.asyncio
async def test_valid_event_crud(valid_events: List[EventFixture]):
async def test_valid_event_crud(valid_events: list[EventFixture]):
author = "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
reply_event_id = "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894"
@ -60,7 +39,7 @@ async def test_valid_event_crud(valid_events: List[EventFixture]):
# insert all events in DB before doing an query
for e in all_events:
await create_event(RELAY_ID, e, None)
await create_event(e)
for f in valid_events:
await get_by_id(f.data, f.name)
@ -85,90 +64,90 @@ async def get_by_id(data: NostrEvent, test_name: str):
), f"Restored event is different for fixture '{test_name}'"
async def filter_by_id(all_events: List[NostrEvent], data: NostrEvent, test_name: str):
filter = NostrFilter(ids=[data.id])
async def filter_by_id(all_events: list[NostrEvent], data: NostrEvent, test_name: str):
nostr_filter = NostrFilter(ids=[data.id])
events = await get_events(RELAY_ID, filter)
events = await get_events(RELAY_ID, nostr_filter)
assert len(events) == 1, f"Expected one queried event '{test_name}'"
assert events[0].json() != json.dumps(
data.json()
), f"Queried event is different for fixture '{test_name}'"
filtered_events = [e for e in all_events if filter.matches(e)]
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 1, f"Expected one filter event '{test_name}'"
assert filtered_events[0].json() != json.dumps(
data.json()
), f"Filtered event is different for fixture '{test_name}'"
async def filter_by_author(all_events: List[NostrEvent], author):
filter = NostrFilter(authors=[author])
events_by_author = await get_events(RELAY_ID, filter)
assert len(events_by_author) == 5, f"Failed to query by authors"
async def filter_by_author(all_events: list[NostrEvent], author):
nostr_filter = NostrFilter(authors=[author])
events_by_author = await get_events(RELAY_ID, nostr_filter)
assert len(events_by_author) == 5, "Failed to query by authors"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 5, f"Failed to filter by authors"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 5, "Failed to filter by authors"
async def filter_by_tag_p(all_events: List[NostrEvent], author):
async def filter_by_tag_p(all_events: list[NostrEvent], author):
# todo: check why constructor does not work for fields with aliases (#e, #p)
filter = NostrFilter()
filter.p.append(author)
nostr_filter = NostrFilter()
nostr_filter.p.append(author)
events_related_to_author = await get_events(RELAY_ID, filter)
assert len(events_related_to_author) == 5, f"Failed to query by tag 'p'"
events_related_to_author = await get_events(RELAY_ID, nostr_filter)
assert len(events_related_to_author) == 5, "Failed to query by tag 'p'"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 5, f"Failed to filter by tag 'p'"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 5, "Failed to filter by tag 'p'"
async def filter_by_tag_e(all_events: List[NostrEvent], event_id):
filter = NostrFilter()
filter.e.append(event_id)
async def filter_by_tag_e(all_events: list[NostrEvent], event_id):
nostr_filter = NostrFilter()
nostr_filter.e.append(event_id)
events_related_to_event = await get_events(RELAY_ID, filter)
assert len(events_related_to_event) == 2, f"Failed to query by tag 'e'"
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
assert len(events_related_to_event) == 2, "Failed to query by tag 'e'"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 2, f"Failed to filter by tag 'e'"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 2, "Failed to filter by tag 'e'"
async def filter_by_tag_e_and_p(
all_events: List[NostrEvent], author, event_id, reply_event_id
all_events: list[NostrEvent], author, event_id, reply_event_id
):
filter = NostrFilter()
filter.p.append(author)
filter.e.append(event_id)
nostr_filter = NostrFilter()
nostr_filter.p.append(author)
nostr_filter.e.append(event_id)
events_related_to_event = await get_events(RELAY_ID, filter)
assert len(events_related_to_event) == 1, f"Failed to quert by tags 'e' & 'p'"
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
assert len(events_related_to_event) == 1, "Failed to quert by tags 'e' & 'p'"
assert (
events_related_to_event[0].id == reply_event_id
), f"Failed to query the right event by tags 'e' & 'p'"
), "Failed to query the right event by tags 'e' & 'p'"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 1, f"Failed to filter by tags 'e' & 'p'"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 1, "Failed to filter by tags 'e' & 'p'"
assert (
filtered_events[0].id == reply_event_id
), f"Failed to find the right event by tags 'e' & 'p'"
), "Failed to find the right event by tags 'e' & 'p'"
async def filter_by_tag_e_p_and_author(
all_events: List[NostrEvent], author, event_id, reply_event_id
all_events: list[NostrEvent], author, event_id, reply_event_id
):
filter = NostrFilter(authors=[author])
filter.p.append(author)
filter.e.append(event_id)
events_related_to_event = await get_events(RELAY_ID, filter)
nostr_filter = NostrFilter(authors=[author])
nostr_filter.p.append(author)
nostr_filter.e.append(event_id)
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
assert (
len(events_related_to_event) == 1
), f"Failed to query by 'author' and tags 'e' & 'p'"
), "Failed to query by 'author' and tags 'e' & 'p'"
assert (
events_related_to_event[0].id == reply_event_id
), f"Failed to query the right event by 'author' and tags 'e' & 'p'"
), "Failed to query the right event by 'author' and tags 'e' & 'p'"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 1, f"Failed to filter by 'author' and tags 'e' & 'p'"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 1, "Failed to filter by 'author' and tags 'e' & 'p'"
assert (
filtered_events[0].id == reply_event_id
), f"Failed to filter the right event by 'author' and tags 'e' & 'p'"
), "Failed to filter the right event by 'author' and tags 'e' & 'p'"

17
tests/test_init.py Normal file
View file

@ -0,0 +1,17 @@
import pytest
from fastapi import APIRouter
from .. import nostrrelay_ext, nostrrelay_start, nostrrelay_stop
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(nostrrelay_ext)
@pytest.mark.asyncio
async def test_start_and_stop():
nostrrelay_start()
await nostrrelay_stop()

9
toc.md
View file

@ -1,22 +1,29 @@
# Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
## 2. License
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
## 3. No Warranty
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
## 4. Limitation of Liability
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
## 5. Modification of Terms
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
## 6. General Provisions
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].
If you have any questions about these Terms, please contact the developer at [developer's contact information].

2293
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,23 +1,18 @@
from http import HTTPStatus
from typing import List, Optional
from fastapi import Depends, Request, WebSocket
from fastapi.exceptions import HTTPException
from loguru import logger
from pydantic.types import UUID4
from starlette.responses import JSONResponse
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo
from lnbits.core.services import create_invoice
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from starlette.responses import JSONResponse
from . import nostrrelay_ext, scheduled_tasks
from .client_manager import client_manager
from .crud import (
create_account,
create_relay,
@ -34,13 +29,14 @@ from .crud import (
)
from .helpers import extract_domain, normalize_public_key, relay_info_response
from .models import BuyOrder, NostrAccount, NostrPartialAccount
from .relay.client_manager import NostrClientConnection, NostrClientManager
from .relay.client_manager import NostrClientConnection
from .relay.relay import NostrRelay
client_manager = NostrClientManager()
nostrrelay_api_router = APIRouter()
@nostrrelay_ext.websocket("/{relay_id}")
@nostrrelay_api_router.websocket("/{relay_id}")
@nostrrelay_api_router.websocket("/{relay_id}/")
async def websocket_endpoint(relay_id: str, websocket: WebSocket):
client = NostrClientConnection(relay_id=relay_id, websocket=websocket)
client_accepted = await client_manager.add_client(client)
@ -54,33 +50,26 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket):
client_manager.remove_client(client)
@nostrrelay_ext.post("/api/v1/relay")
@nostrrelay_api_router.post("/api/v1/relay")
async def api_create_relay(
data: NostrRelay,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> NostrRelay:
data.user_id = key_info.wallet.user
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.admin, "Only admin users can set the relay ID"
else:
data.id = urlsafe_short_hash()[:8]
try:
data.config.domain = extract_domain(str(request.url))
relay = await create_relay(wallet.wallet.user, data)
return relay
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create relay",
)
data.meta.domain = extract_domain(str(request.url))
relay = await create_relay(data)
return relay
@nostrrelay_ext.patch("/api/v1/relay/{relay_id}")
@nostrrelay_api_router.patch("/api/v1/relay/{relay_id}")
async def api_update_relay(
relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> NostrRelay:
@ -90,98 +79,66 @@ async def api_update_relay(
detail="Cannot change the relay id",
)
try:
relay = await get_relay(wallet.wallet.user, data.id)
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)
relay = await get_relay(wallet.wallet.user, data.id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update relay",
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)})
updated_relay.user_id = wallet.wallet.user
updated_relay = await update_relay(updated_relay)
@nostrrelay_ext.put("/api/v1/relay/{relay_id}")
# 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}")
async def api_toggle_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> NostrRelay:
try:
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)
relay = await get_relay(wallet.wallet.user, relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update relay",
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
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_ext.get("/api/v1/relay")
@nostrrelay_api_router.get("/api/v1/relay")
async def api_get_relays(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[NostrRelay]:
try:
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",
)
) -> list[NostrRelay]:
return await get_relays(wallet.wallet.user)
@nostrrelay_ext.get("/api/v1/relay-info")
@nostrrelay_api_router.get("/api/v1/relay-info")
async def api_get_relay_info() -> JSONResponse:
return relay_info_response(NostrRelay.info())
@nostrrelay_ext.get("/api/v1/relay/{relay_id}")
@nostrrelay_api_router.get("/api/v1/relay/{relay_id}")
async def api_get_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> Optional[NostrRelay]:
try:
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",
)
) -> NostrRelay | None:
relay = await get_relay(wallet.wallet.user, relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
@ -190,106 +147,65 @@ async def api_get_relay(
return relay
@nostrrelay_ext.put("/api/v1/account")
@nostrrelay_api_router.put("/api/v1/account", dependencies=[Depends(require_admin_key)])
async def api_create_or_update_account(
data: NostrPartialAccount,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> NostrAccount:
try:
data.pubkey = normalize_public_key(data.pubkey)
account = await get_account(data.relay_id, data.pubkey)
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),
)
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",
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)
if data.blocked is not None:
account.blocked = data.blocked
if data.allowed is not None:
account.allowed = data.allowed
return await update_account(account)
@nostrrelay_ext.delete("/api/v1/account/{relay_id}/{pubkey}")
@nostrrelay_api_router.delete(
"/api/v1/account/{relay_id}/{pubkey}", dependencies=[Depends(require_admin_key)]
)
async def api_delete_account(
relay_id: str,
pubkey: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
pubkey = normalize_public_key(pubkey)
return await delete_account(relay_id, pubkey)
except ValueError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(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",
)
detail=f"Invalid pubkey: {ex!s}",
) from ex
return await delete_account(relay_id, pubkey)
@nostrrelay_ext.get("/api/v1/account")
@nostrrelay_api_router.get("/api/v1/account")
async def api_get_accounts(
relay_id: str,
allowed: bool = False,
blocked: bool = True,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[NostrAccount]:
try:
# make sure the user has access to the relay
relay = await get_relay(wallet.wallet.user, relay_id)
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:
) -> list[NostrAccount]:
# make sure the user has access to the relay
relay = await get_relay(wallet.wallet.user, relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch accounts",
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
accounts = await get_accounts(relay.id, allowed, blocked)
return accounts
@nostrrelay_ext.delete("/api/v1/relay/{relay_id}")
@nostrrelay_api_router.delete("/api/v1/relay/{relay_id}")
async def api_delete_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
@ -302,77 +218,55 @@ async def api_delete_relay(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delete relay",
)
) from ex
@nostrrelay_ext.put("/api/v1/pay")
@nostrrelay_api_router.put("/api/v1/pay")
async def api_pay_to_join(data: BuyOrder):
try:
pubkey = normalize_public_key(data.pubkey)
relay = await get_relay_by_id(data.relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
amount = 0
storage_to_buy = 0
if data.action == "join":
if relay.is_free_to_join:
raise ValueError("Relay is free to join")
amount = int(relay.config.cost_to_join)
elif data.action == "storage":
if relay.config.storage_cost_value == 0:
raise ValueError("Relay storage cost is zero. Cannot buy!")
if data.units_to_buy == 0:
raise ValueError("Must specify how much storage to buy!")
storage_to_buy = data.units_to_buy * relay.config.storage_cost_value * 1024
if relay.config.storage_cost_unit == "MB":
storage_to_buy *= 1024
amount = data.units_to_buy * relay.config.storage_cost_value
else:
raise ValueError(f"Unknown action: '{data.action}'")
_, payment_request = await create_invoice(
wallet_id=relay.config.wallet,
amount=amount,
memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}",
extra={
"tag": "nostrrely",
"action": data.action,
"relay_id": relay.id,
"pubkey": pubkey,
"storage_to_buy": storage_to_buy,
},
pubkey = normalize_public_key(data.pubkey)
relay = await get_relay_by_id(data.relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
return {"invoice": payment_request}
except ValueError as ex:
amount = 0
storage_to_buy = 0
if data.action == "join":
if relay.is_free_to_join:
raise ValueError("Relay is free to join")
amount = int(relay.meta.cost_to_join)
elif data.action == "storage":
if relay.meta.storage_cost_value == 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Relay storage cost is zero. Cannot buy!",
)
if data.units_to_buy == 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Must specify how much storage to buy!",
)
storage_to_buy = data.units_to_buy * relay.meta.storage_cost_value * 1024
if relay.meta.storage_cost_unit == "MB":
storage_to_buy *= 1024
amount = data.units_to_buy * relay.meta.storage_cost_value
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(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 invoice for client to join",
detail=f"Unknown action: '{data.action}'",
)
@nostrrelay_ext.delete("/api/v1", status_code=HTTPStatus.OK)
async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
for t in scheduled_tasks:
try:
t.cancel()
except Exception as ex:
logger.warning(ex)
try:
await client_manager.stop()
except Exception as ex:
logger.warning(ex)
return {"success": True}
payment = await create_invoice(
wallet_id=relay.meta.wallet,
amount=amount,
memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}",
extra={
"tag": "nostrrely",
"action": data.action,
"relay_id": relay.id,
"pubkey": pubkey,
"storage_to_buy": storage_to_buy,
},
)
return {"invoice": payment.bolt11}