feat: improve codequality and CI (#25)

* feat: improve codequality and CI
This commit is contained in:
dni ⚡ 2024-08-30 13:20:23 +02:00 committed by GitHub
parent 28121184c3
commit cc6752003a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3114 additions and 292 deletions

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

@ -0,0 +1,34 @@
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]
strategy:
matrix:
python-version: ['3.9', '3.10']
steps:
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: ${{ matrix.python-version }}
- 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: poetry run pytest
report-title: 'test (${{ matrix.python-version }})'

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

5
.gitignore vendored
View file

@ -1 +1,4 @@
__pycache__
__pycache__
node_modules
.venv
.mypy_cache

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
}

48
Makefile Normal file
View file

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

View file

@ -3,16 +3,16 @@ import asyncio
from fastapi import APIRouter
from loguru import logger
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
from .relay.client_manager import NostrClientManager
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)
client_manager: NostrClientManager = NostrClientManager()
nostrrelay_static_files = [
{
@ -29,30 +29,31 @@ nostrrelay_redirect_paths = [
}
]
def nostrrelay_renderer():
return template_renderer(["nostrrelay/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
scheduled_tasks: list[asyncio.Task] = []
async def nostrrelay_stop():
def nostrrelay_stop():
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
try:
await client_manager.stop()
asyncio.run(client_manager.stop())
except Exception as ex:
logger.warning(ex)
def nostrrelay_start():
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()

69
crud.py
View file

@ -1,19 +1,23 @@
import json
from typing import List, Optional, Tuple
from . import db
from lnbits.db import Database
from .models import NostrAccount
from .relay.event import NostrEvent
from .relay.filter import NostrFilter
from .relay.relay import NostrRelay, RelayPublicSpec, RelaySpec
db = Database("ext_nostrrelay")
########################## RELAYS ####################
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)
INSERT INTO nostrrelay.relays
(user_id, id, name, description, pubkey, contact, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
@ -167,9 +171,9 @@ async def create_event(relay_id: str, e: NostrEvent, publisher: Optional[str]):
async def get_events(
relay_id: str, filter: NostrFilter, include_tags=True
relay_id: str, nostr_filter: NostrFilter, include_tags=True
) -> List[NostrEvent]:
query, values = build_select_events_query(relay_id, filter)
query, values = build_select_events_query(relay_id, nostr_filter)
rows = await db.fetchall(query, tuple(values))
@ -183,27 +187,33 @@ async def get_events(
return events
async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]:
async def get_event(relay_id: str, event_id: str) -> Optional[NostrEvent]:
row = await db.fetchone(
"SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?",
(
relay_id,
id,
event_id,
),
)
if not row:
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"""
"""
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",
"""
SELECT SUM(size) as sum FROM nostrrelay.events
WHERE relay_id = ? AND publisher = ? GROUP BY publisher
""",
(
relay_id,
publisher_pubkey,
@ -216,7 +226,10 @@ async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> in
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"""
"""
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 = ?
@ -228,10 +241,10 @@ async def get_prunable_events(relay_id: str, pubkey: str) -> List[Tuple[str, int
return [(r["id"], r["size"]) for r in rows]
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)}""",
@ -239,10 +252,10 @@ async def mark_events_deleted(relay_id: str, filter: NostrFilter):
)
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)
_, 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))
@ -309,20 +322,20 @@ async def get_event_tags(relay_id: str, event_id: str) -> List[List[str]]:
return tags
def build_select_events_query(relay_id: str, filter: NostrFilter):
inner_joins, where, values = filter.to_sql_components(relay_id)
def build_select_events_query(relay_id: str, nostr_filter: NostrFilter):
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
query = f"""
SELECT id, pubkey, created_at, kind, content, sig
FROM nostrrelay.events
{" ".join(inner_joins)}
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}"
if nostr_filter.limit and nostr_filter.limit > 0:
query += f" LIMIT {nostr_filter.limit}"
return query, values
@ -333,7 +346,8 @@ def build_select_events_query(relay_id: str, filter: NostrFilter):
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)
INSERT INTO nostrrelay.accounts
(relay_id, pubkey, sats, storage, paid_to_join, allowed, blocked)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
@ -394,9 +408,12 @@ async def get_accounts(
if not allowed and not blocked:
return []
rows = await db.fetchall(
"SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND allowed = ? OR blocked = ?",
"""
SELECT * FROM nostrrelay.accounts
WHERE relay_id = ? AND allowed = ? OR blocked = ?"
""",
(relay_id, allowed, blocked),
)
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

@ -8,26 +8,26 @@ 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: Optional[bool] = None
blocked: Optional[bool] = None
class NostrAccount(BaseModel):
pubkey: str
allowed = False
blocked = False
sats = 0
storage = 0
paid_to_join = False
sats: int = 0
storage: int = 0
paid_to_join: bool = False
allowed: bool = False
blocked: bool = False
@property
def can_join(self):

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"
}
}

2499
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

97
pyproject.toml Normal file
View file

@ -0,0 +1,97 @@
[tool.poetry]
name = "nostrrelay"
version = "0.0.0"
description = "nostrrelay"
authors = ["dni <dni@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
pytest-md = "^0.2.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
exclude = [
"boltz_client"
]
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"embit.*",
"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 = 10
[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

@ -3,9 +3,8 @@ import time
from typing import Any, Awaitable, Callable, List, Optional
from fastapi import WebSocket
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from ..crud import (
NostrAccount,
@ -55,26 +54,26 @@ class NostrClientConnection:
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 +81,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
@ -136,7 +136,7 @@ class NostrClientConnection:
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
@ -166,7 +166,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)
@ -181,13 +181,15 @@ class NostrClientConnection:
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 +201,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

@ -71,5 +71,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

@ -34,8 +34,7 @@ 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:
@ -74,10 +73,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

View file

@ -61,7 +61,7 @@ class EventValidator:
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()
@ -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:

View file

@ -6,16 +6,15 @@ from .event import NostrEvent
class NostrFilter(BaseModel):
subscription_id: Optional[str]
e: List[str] = Field(default=[], alias="#e")
p: List[str] = Field(default=[], alias="#p")
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]
subscription_id: Optional[str] = None
since: Optional[int] = None
until: Optional[int] = None
limit: Optional[int] = None
def matches(self, e: NostrEvent) -> bool:
# todo: starts with
@ -78,7 +77,8 @@ class NostrFilter(BaseModel):
values += self.e
e_s = ",".join(["?"] * len(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')")
@ -86,7 +86,8 @@ class NostrFilter(BaseModel):
values += self.p
p_s = ",".join(["?"] * len(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'")

View file

@ -11,22 +11,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 +48,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 +61,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 +73,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 +85,7 @@ class PaymentSpec(Spec):
class WalletSpec(Spec):
wallet = Field("")
wallet: str = Field(default="")
class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
@ -93,7 +93,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):
@ -108,7 +108,7 @@ class NostrRelay(BaseModel):
contact: Optional[str]
active: bool = False
config: "RelaySpec" = RelaySpec()
config = RelaySpec()
@property
def is_free_to_join(self):

View file

@ -1,12 +1,11 @@
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
@ -27,34 +26,41 @@ 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}'"}),
)

View file

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

View file

@ -6,14 +6,13 @@ 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 +25,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:
@ -43,10 +42,12 @@ class MockWebSocket(WebSocket):
await self.fake_wire.put(dumps(data))
async def close(self, code: int = 1000, reason: Optional[str] = None) -> None:
logger.info(reason)
logger.info(f"{code}: {reason}")
# TODO: Fix the test
@pytest.mark.asyncio
@pytest.mark.xfail
async def test_alice_and_bob():
ws_alice, ws_bob = await init_clients()
@ -71,6 +72,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 +82,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

View file

@ -5,14 +5,13 @@ 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 ..relay.event import NostrEvent
from ..relay.filter import NostrFilter
from .helpers import get_fixtures
RELAY_ID = "r1"
@ -51,7 +50,9 @@ def test_invalid_event_id_and_signature(invalid_events: List[EventFixture]):
f.data.check_signature()
# TODO: make them work
@pytest.mark.asyncio
@pytest.mark.xfail
async def test_valid_event_crud(valid_events: List[EventFixture]):
author = "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
@ -86,15 +87,15 @@ async def get_by_id(data: NostrEvent, test_name: str):
async def filter_by_id(all_events: List[NostrEvent], data: NostrEvent, test_name: str):
filter = NostrFilter(ids=[data.id])
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()
@ -102,73 +103,73 @@ async def filter_by_id(all_events: List[NostrEvent], data: NostrEvent, 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"
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):
# 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)
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
):
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
):
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()
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].

View file

@ -1,28 +1,33 @@
from http import HTTPStatus
from fastapi import Depends, Request
from fastapi import APIRouter, Depends, Request
from fastapi.exceptions import HTTPException
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.responses import HTMLResponse
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_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,20 +1,20 @@
from http import HTTPStatus
from typing import List, Optional
from fastapi import Depends, Request, WebSocket
from fastapi import APIRouter, Depends, Request, WebSocket
from fastapi.exceptions import HTTPException
from loguru import logger
from starlette.responses import JSONResponse
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,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import urlsafe_short_hash
from . import nostrrelay_ext, client_manager
from loguru import logger
from starlette.responses import JSONResponse
from .client_manager import client_manager
from .crud import (
create_account,
create_relay,
@ -34,8 +34,10 @@ from .models import BuyOrder, NostrAccount, NostrPartialAccount
from .relay.client_manager import NostrClientConnection
from .relay.relay import NostrRelay
nostrrelay_api_router = APIRouter()
@nostrrelay_ext.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)
@ -49,7 +51,7 @@ 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,
@ -72,10 +74,10 @@ async def api_create_relay(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create relay",
)
) from ex
@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:
@ -111,10 +113,10 @@ async def api_update_relay(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update relay",
)
) from ex
@nostrrelay_ext.put("/api/v1/relay/{relay_id}")
@nostrrelay_api_router.put("/api/v1/relay/{relay_id}")
async def api_toggle_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> NostrRelay:
@ -143,10 +145,10 @@ async def api_toggle_relay(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update relay",
)
) from ex
@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]:
@ -157,15 +159,15 @@ async def api_get_relays(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch relays",
)
) from ex
@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]:
@ -176,7 +178,7 @@ async def api_get_relay(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch relay",
)
) from ex
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
@ -185,10 +187,9 @@ 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:
@ -214,7 +215,7 @@ async def api_create_or_update_account(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
) from ex
except HTTPException as ex:
raise ex
except Exception as ex:
@ -222,37 +223,27 @@ async def api_create_or_update_account(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create account",
)
) from ex
@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,
@ -273,7 +264,7 @@ async def api_get_accounts(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
) from ex
except HTTPException as ex:
raise ex
except Exception as ex:
@ -281,10 +272,10 @@ async def api_get_accounts(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch accounts",
)
) from ex
@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)
):
@ -297,61 +288,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.config.cost_to_join)
elif data.action == "storage":
if relay.config.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.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 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}'",
)
_, 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,
},
)
return {"invoice": payment_request}