Compare commits
10 commits
94d383baff
...
35584a230f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35584a230f | ||
|
|
15079c3e58 | ||
|
|
22df5868de | ||
|
|
687d7b89c1 | ||
|
|
5a1a400f45 | ||
|
|
3dc066fbd4 | ||
|
|
73054fd5ce | ||
|
|
2bdbbb274d | ||
|
|
cc6752003a | ||
|
|
28121184c3 |
39 changed files with 3892 additions and 1414 deletions
29
.github/workflows/ci.yml
vendored
Normal file
29
.github/workflows/ci.yml
vendored
Normal 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'
|
||||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
|
|
@ -1,10 +1,9 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -34,12 +33,12 @@ jobs:
|
||||||
- name: Create pull request in extensions repo
|
- name: Create pull request in extensions repo
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
repo_name: "${{ github.event.repository.name }}"
|
repo_name: '${{ github.event.repository.name }}'
|
||||||
tag: "${{ github.ref_name }}"
|
tag: '${{ github.ref_name }}'
|
||||||
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
||||||
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ 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 }}"
|
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"
|
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
||||||
run: |
|
run: |
|
||||||
cd lnbits-extensions
|
cd lnbits-extensions
|
||||||
git checkout -b $branch
|
git checkout -b $branch
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1 +1,5 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
|
node_modules
|
||||||
|
.venv
|
||||||
|
.mypy_cache
|
||||||
|
data
|
||||||
12
.prettierrc
Normal file
12
.prettierrc
Normal 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
49
Makefile
Normal 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"
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
## Supported NIPs
|
## Supported NIPs
|
||||||
|
|
||||||
- [x] **NIP-01**: Basic protocol flow
|
- [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
|
- [x] **NIP-02**: Contact List and Petnames
|
||||||
- `kind: 3`: delete past contact lists as soon as the relay receives a new one
|
- `kind: 3`: delete past contact lists as soon as the relay receives a new one
|
||||||
- [x] **NIP-04**: Encrypted Direct Message
|
- [x] **NIP-04**: Encrypted Direct Message
|
||||||
|
|
@ -36,8 +40,8 @@
|
||||||
- not planned
|
- not planned
|
||||||
- [x] **NIP-28** Public Chat
|
- [x] **NIP-28** Public Chat
|
||||||
- `kind: 41`: handled similar to `kind 0` metadata events
|
- `kind: 41`: handled similar to `kind 0` metadata events
|
||||||
- [ ] **NIP-33**: Parameterized Replaceable Events
|
- [x] **NIP-33**: Addressable Events (moved to NIP-01)
|
||||||
- todo
|
- ✅ Implemented as part of NIP-01 addressable events
|
||||||
- [ ] **NIP-40**: Expiration Timestamp
|
- [ ] **NIP-40**: Expiration Timestamp
|
||||||
- todo
|
- todo
|
||||||
- [x] **NIP-42**: Authentication of clients to relays
|
- [x] **NIP-42**: Authentication of clients to relays
|
||||||
|
|
|
||||||
47
__init__.py
47
__init__.py
|
|
@ -1,15 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.db import Database
|
from .client_manager import client_manager
|
||||||
from lnbits.helpers import template_renderer
|
from .crud import db
|
||||||
from lnbits.tasks import catch_everything_and_restart
|
from .tasks import wait_for_paid_invoices
|
||||||
|
from .views import nostrrelay_generic_router
|
||||||
db = Database("ext_nostrrelay")
|
from .views_api import nostrrelay_api_router
|
||||||
|
|
||||||
nostrrelay_ext: APIRouter = APIRouter(prefix="/nostrrelay", tags=["NostrRelay"])
|
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 = [
|
nostrrelay_static_files = [
|
||||||
{
|
{
|
||||||
|
|
@ -26,19 +29,31 @@ nostrrelay_redirect_paths = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
scheduled_tasks: List[asyncio.Task] = []
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
def nostrrelay_renderer():
|
async def nostrrelay_stop():
|
||||||
return template_renderer(["nostrrelay/templates"])
|
for task in scheduled_tasks:
|
||||||
|
try:
|
||||||
|
task.cancel()
|
||||||
from .tasks import wait_for_paid_invoices
|
except Exception as ex:
|
||||||
from .views import * # noqa
|
logger.warning(ex)
|
||||||
from .views_api import * # noqa
|
try:
|
||||||
|
await client_manager.stop()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
def nostrrelay_start():
|
def nostrrelay_start():
|
||||||
loop = asyncio.get_event_loop()
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
|
||||||
|
task = create_permanent_unique_task("ext_nostrrelay", wait_for_paid_invoices)
|
||||||
scheduled_tasks.append(task)
|
scheduled_tasks.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"db",
|
||||||
|
"nostrrelay_ext",
|
||||||
|
"nostrrelay_start",
|
||||||
|
"nostrrelay_stop",
|
||||||
|
]
|
||||||
|
|
|
||||||
3
client_manager.py
Normal file
3
client_manager.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .relay.client_manager import NostrClientManager
|
||||||
|
|
||||||
|
client_manager: NostrClientManager = NostrClientManager()
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
"name": "Nostr Relay",
|
"name": "Nostr Relay",
|
||||||
"short_description": "One click launch your own relay!",
|
"short_description": "One click launch your own relay!",
|
||||||
"tile": "/nostrrelay/static/image/nostrrelay.png",
|
"tile": "/nostrrelay/static/image/nostrrelay.png",
|
||||||
"min_lnbits_version": "0.12.6",
|
"min_lnbits_version": "1.0.0",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "motorina0",
|
"name": "motorina0",
|
||||||
"uri": "https://github.com/motorina0",
|
"uri": "https://github.com/motorina0",
|
||||||
"role": "Contributor"
|
"role": "Contributor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dni",
|
||||||
|
"uri": "https://github.com/dni",
|
||||||
|
"role": "Contributor"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"images": [
|
"images": [
|
||||||
|
|
|
||||||
431
crud.py
431
crud.py
|
|
@ -1,111 +1,71 @@
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
from . import db
|
from lnbits.db import Database
|
||||||
from .models import NostrAccount
|
|
||||||
|
from .models import NostrAccount, NostrEventTags
|
||||||
from .relay.event import NostrEvent
|
from .relay.event import NostrEvent
|
||||||
from .relay.filter import NostrFilter
|
from .relay.filter import NostrFilter
|
||||||
from .relay.relay import NostrRelay, RelayPublicSpec, RelaySpec
|
from .relay.relay import NostrRelay, RelayPublicSpec
|
||||||
|
|
||||||
########################## RELAYS ####################
|
db = Database("ext_nostrrelay")
|
||||||
|
|
||||||
|
|
||||||
async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay:
|
async def create_relay(relay: NostrRelay) -> NostrRelay:
|
||||||
await db.execute(
|
await db.insert("nostrrelay.relays", relay)
|
||||||
"""
|
|
||||||
INSERT INTO nostrrelay.relays (user_id, id, name, description, pubkey, contact, meta)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
user_id,
|
|
||||||
r.id,
|
|
||||||
r.name,
|
|
||||||
r.description,
|
|
||||||
r.pubkey,
|
|
||||||
r.contact,
|
|
||||||
json.dumps(dict(r.config)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
relay = await get_relay(user_id, r.id)
|
|
||||||
assert relay, "Created relay cannot be retrieved"
|
|
||||||
return relay
|
return relay
|
||||||
|
|
||||||
|
|
||||||
async def update_relay(user_id: str, r: NostrRelay) -> NostrRelay:
|
async def update_relay(relay: NostrRelay) -> NostrRelay:
|
||||||
await db.execute(
|
await db.update("nostrrelay.relays", relay, "WHERE user_id = :user_id AND id = :id")
|
||||||
"""
|
return relay
|
||||||
UPDATE nostrrelay.relays
|
|
||||||
SET (name, description, pubkey, contact, active, meta) = (?, ?, ?, ?, ?, ?)
|
|
||||||
WHERE user_id = ? AND id = ?
|
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",
|
||||||
r.name,
|
{"user_id": user_id, "id": relay_id},
|
||||||
r.description,
|
NostrRelay,
|
||||||
r.pubkey,
|
|
||||||
r.contact,
|
|
||||||
r.active,
|
|
||||||
json.dumps(dict(r.config)),
|
|
||||||
user_id,
|
|
||||||
r.id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
async def get_relay_by_id(relay_id: str) -> NostrRelay | None:
|
||||||
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]:
|
|
||||||
"""Note: it does not require `user_id`. Can read any relay. Use it with care."""
|
"""Note: it does not require `user_id`. Can read any relay. Use it with care."""
|
||||||
row = await db.fetchone(
|
return await db.fetchone(
|
||||||
"""SELECT * FROM nostrrelay.relays WHERE id = ?""",
|
"SELECT * FROM nostrrelay.relays WHERE id = :id",
|
||||||
(relay_id,),
|
{"id": relay_id},
|
||||||
|
NostrRelay,
|
||||||
)
|
)
|
||||||
|
|
||||||
return NostrRelay.from_row(row) if row else None
|
|
||||||
|
|
||||||
|
async def get_relays(user_id: str) -> list[NostrRelay]:
|
||||||
async def get_relays(user_id: str) -> List[NostrRelay]:
|
return await db.fetchall(
|
||||||
rows = await db.fetchall(
|
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id ORDER BY id ASC",
|
||||||
"""SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""",
|
{"user_id": user_id},
|
||||||
(user_id,),
|
NostrRelay,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [NostrRelay.from_row(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_config_for_all_active_relays() -> dict:
|
async def get_config_for_all_active_relays() -> dict:
|
||||||
rows = await db.fetchall(
|
relays = await db.fetchall(
|
||||||
"SELECT id, meta FROM nostrrelay.relays WHERE active = true",
|
"SELECT * FROM nostrrelay.relays WHERE active = true",
|
||||||
|
model=NostrRelay,
|
||||||
)
|
)
|
||||||
active_relay_configs = {}
|
active_relay_configs = {}
|
||||||
for r in rows:
|
for relay in relays:
|
||||||
active_relay_configs[r["id"]] = RelaySpec(
|
active_relay_configs[relay.id] = relay.meta
|
||||||
**json.loads(r["meta"])
|
|
||||||
) # todo: from_json
|
|
||||||
|
|
||||||
return active_relay_configs
|
return active_relay_configs
|
||||||
|
|
||||||
|
|
||||||
async def get_public_relay(relay_id: str) -> Optional[dict]:
|
async def get_public_relay(relay_id: str) -> dict | None:
|
||||||
row = await db.fetchone(
|
relay = await db.fetchone(
|
||||||
"""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,)
|
"SELECT * FROM nostrrelay.relays WHERE id = :id",
|
||||||
|
{"id": relay_id},
|
||||||
|
NostrRelay,
|
||||||
)
|
)
|
||||||
|
if not relay:
|
||||||
if not row:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
relay = NostrRelay.from_row(row)
|
|
||||||
return {
|
return {
|
||||||
**NostrRelay.info(),
|
**NostrRelay.info(),
|
||||||
"id": relay.id,
|
"id": relay.id,
|
||||||
|
|
@ -113,142 +73,144 @@ async def get_public_relay(relay_id: str) -> Optional[dict]:
|
||||||
"description": relay.description,
|
"description": relay.description,
|
||||||
"pubkey": relay.pubkey,
|
"pubkey": relay.pubkey,
|
||||||
"contact": relay.contact,
|
"contact": relay.contact,
|
||||||
"config": RelayPublicSpec(**dict(relay.config)).dict(by_alias=True),
|
"config": RelayPublicSpec(**relay.meta.dict()).dict(by_alias=True),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def delete_relay(user_id: str, relay_id: str):
|
async def delete_relay(user_id: str, relay_id: str):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""",
|
"DELETE FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
|
||||||
(
|
{"user_id": user_id, "id": relay_id},
|
||||||
user_id,
|
|
||||||
relay_id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
########################## EVENTS ####################
|
async def create_event(event: NostrEvent):
|
||||||
async def create_event(relay_id: str, e: NostrEvent, publisher: Optional[str]):
|
event_ = await get_event(event.relay_id, event.id)
|
||||||
publisher = publisher if publisher else e.pubkey
|
if event_:
|
||||||
await db.execute(
|
return None
|
||||||
"""
|
await db.insert("nostrrelay.events", event)
|
||||||
INSERT INTO nostrrelay.events (
|
|
||||||
relay_id,
|
|
||||||
publisher,
|
|
||||||
id,
|
|
||||||
pubkey,
|
|
||||||
created_at,
|
|
||||||
kind,
|
|
||||||
content,
|
|
||||||
sig,
|
|
||||||
size
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (relay_id, id) DO NOTHING
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
relay_id,
|
|
||||||
publisher,
|
|
||||||
e.id,
|
|
||||||
e.pubkey,
|
|
||||||
e.created_at,
|
|
||||||
e.kind,
|
|
||||||
e.content,
|
|
||||||
e.sig,
|
|
||||||
e.size_bytes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# todo: optimize with bulk insert
|
# todo: optimize with bulk insert
|
||||||
for tag in e.tags:
|
for tag in event.tags:
|
||||||
name, value, *rest = tag
|
name, value, *rest = tag
|
||||||
extra = json.dumps(rest) if rest else None
|
extra = json.dumps(rest) if rest else None
|
||||||
await create_event_tags(relay_id, e.id, name, value, extra)
|
_tag = NostrEventTags(
|
||||||
|
relay_id=event.relay_id,
|
||||||
|
event_id=event.id,
|
||||||
|
name=name,
|
||||||
|
value=value,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
await create_event_tags(_tag)
|
||||||
|
|
||||||
|
|
||||||
async def get_events(
|
async def get_events(
|
||||||
relay_id: str, filter: NostrFilter, include_tags=True
|
relay_id: str, nostr_filter: NostrFilter, include_tags=True
|
||||||
) -> List[NostrEvent]:
|
) -> list[NostrEvent]:
|
||||||
query, values = build_select_events_query(relay_id, filter)
|
|
||||||
|
|
||||||
rows = await db.fetchall(query, tuple(values))
|
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
|
||||||
|
query = f"""
|
||||||
|
SELECT * FROM nostrrelay.events
|
||||||
|
{" ".join(inner_joins)}
|
||||||
|
WHERE { " AND ".join(where)}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""
|
||||||
|
|
||||||
events = []
|
# todo: check & enforce range
|
||||||
for row in rows:
|
if nostr_filter.limit and nostr_filter.limit > 0:
|
||||||
event = NostrEvent.from_row(row)
|
query += f" LIMIT {nostr_filter.limit}"
|
||||||
|
|
||||||
|
events = await db.fetchall(query, values, NostrEvent)
|
||||||
|
|
||||||
|
for event in events:
|
||||||
if include_tags:
|
if include_tags:
|
||||||
event.tags = await get_event_tags(relay_id, event.id)
|
event.tags = await get_event_tags(relay_id, event.id)
|
||||||
events.append(event)
|
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]:
|
async def get_event(relay_id: str, event_id: str) -> NostrEvent | None:
|
||||||
row = await db.fetchone(
|
event = await db.fetchone(
|
||||||
"SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?",
|
"SELECT * FROM nostrrelay.events WHERE relay_id = :relay_id AND id = :id",
|
||||||
(
|
{"relay_id": relay_id, "id": event_id},
|
||||||
relay_id,
|
NostrEvent,
|
||||||
id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if not row:
|
if not event:
|
||||||
return None
|
return None
|
||||||
|
event.tags = await get_event_tags(relay_id, event_id)
|
||||||
event = NostrEvent.from_row(row)
|
|
||||||
event.tags = await get_event_tags(relay_id, id)
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> int:
|
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.
|
||||||
row = await db.fetchone(
|
Deleted events are also counted
|
||||||
"SELECT SUM(size) as sum FROM nostrrelay.events WHERE relay_id = ? AND publisher = ? GROUP BY publisher",
|
"""
|
||||||
(
|
row: dict = await db.fetchone(
|
||||||
relay_id,
|
"""
|
||||||
publisher_pubkey,
|
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:
|
if not row:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return round(row["sum"])
|
return round(row["sum"])
|
||||||
|
|
||||||
|
|
||||||
async def get_prunable_events(relay_id: str, pubkey: str) -> List[Tuple[str, int]]:
|
async def get_prunable_events(relay_id: str, pubkey: str) -> list[tuple[str, int]]:
|
||||||
"""Return the oldest 10 000 events. Only the `id` and the size are returned, so the data size should be small"""
|
"""
|
||||||
query = """
|
Return the oldest 10 000 events. Only the `id` and the size are returned,
|
||||||
SELECT id, size FROM nostrrelay.events
|
so the data size should be small
|
||||||
WHERE relay_id = ? AND pubkey = ?
|
"""
|
||||||
ORDER BY created_at ASC LIMIT 10000
|
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 [(event.id, event.size_bytes) for event in events]
|
||||||
|
|
||||||
return [(r["id"], r["size"]) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_events_deleted(relay_id: str, filter: NostrFilter):
|
async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter):
|
||||||
if filter.is_empty():
|
if nostr_filter.is_empty():
|
||||||
return None
|
return None
|
||||||
_, where, values = filter.to_sql_components(relay_id)
|
_, where, values = nostr_filter.to_sql_components(relay_id)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""",
|
f"UPDATE nostrrelay.events SET deleted=true WHERE {' AND '.join(where)}",
|
||||||
tuple(values),
|
values,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_events(relay_id: str, filter: NostrFilter):
|
async def delete_events(relay_id: str, nostr_filter: NostrFilter):
|
||||||
if filter.is_empty():
|
if nostr_filter.is_empty():
|
||||||
return None
|
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)}"""
|
if inner_joins:
|
||||||
await db.execute(query, tuple(values))
|
# 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
|
# todo: delete tags
|
||||||
|
|
||||||
|
|
||||||
|
# move to services
|
||||||
async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
|
async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
|
||||||
prunable_events = await get_prunable_events(relay_id, pubkey)
|
prunable_events = await get_prunable_events(relay_id, pubkey)
|
||||||
prunable_event_ids = []
|
prunable_event_ids = []
|
||||||
|
|
@ -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):
|
async def delete_all_events(relay_id: str):
|
||||||
query = "DELETE from nostrrelay.events WHERE relay_id = ?"
|
await db.execute(
|
||||||
await db.execute(query, (relay_id,))
|
"DELETE from nostrrelay.events WHERE relay_id = :id",
|
||||||
|
{"id": relay_id},
|
||||||
|
)
|
||||||
# todo: delete tags
|
# todo: delete tags
|
||||||
|
|
||||||
|
|
||||||
async def create_event_tags(
|
async def create_event_tags(tag: NostrEventTags):
|
||||||
relay_id: str,
|
await db.insert("nostrrelay.event_tags", tag)
|
||||||
event_id: str,
|
|
||||||
tag_name: str,
|
|
||||||
tag_value: str,
|
async def get_event_tags(relay_id: str, event_id: str) -> list[list[str]]:
|
||||||
extra_values: Optional[str],
|
_tags = await db.fetchall(
|
||||||
):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO nostrrelay.event_tags (
|
SELECT * FROM nostrrelay.event_tags
|
||||||
relay_id,
|
WHERE relay_id = :relay_id and event_id = :event_id
|
||||||
event_id,
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
extra
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
""",
|
||||||
(relay_id, event_id, tag_name, tag_value, extra_values),
|
{"relay_id": relay_id, "event_id": event_id},
|
||||||
|
model=NostrEventTags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tags: list[list[str]] = []
|
||||||
async def get_event_tags(relay_id: str, event_id: str) -> List[List[str]]:
|
for tag in _tags:
|
||||||
rows = await db.fetchall(
|
_tag = [tag.name, tag.value]
|
||||||
"SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?",
|
if tag.extra:
|
||||||
(relay_id, event_id),
|
_tag += json.loads(tag.extra)
|
||||||
)
|
tags.append(_tag)
|
||||||
|
|
||||||
tags: List[List[str]] = []
|
|
||||||
for row in rows:
|
|
||||||
tag = [row["name"], row["value"]]
|
|
||||||
extra = row["extra"]
|
|
||||||
if extra:
|
|
||||||
tag += json.loads(extra)
|
|
||||||
tags.append(tag)
|
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
def build_select_events_query(relay_id: str, filter: NostrFilter):
|
async def create_account(account: NostrAccount) -> NostrAccount:
|
||||||
inner_joins, where, values = filter.to_sql_components(relay_id)
|
await db.insert("nostrrelay.accounts", account)
|
||||||
|
|
||||||
query = f"""
|
|
||||||
SELECT id, pubkey, created_at, kind, content, sig
|
|
||||||
FROM nostrrelay.events
|
|
||||||
{" ".join(inner_joins)}
|
|
||||||
WHERE { " AND ".join(where)}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"""
|
|
||||||
|
|
||||||
# todo: check & enforce range
|
|
||||||
if 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"
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
async def update_account(relay_id: str, a: NostrAccount) -> NostrAccount:
|
async def update_account(account: NostrAccount) -> NostrAccount:
|
||||||
await db.execute(
|
await db.update(
|
||||||
"""
|
"nostrrelay.accounts",
|
||||||
UPDATE nostrrelay.accounts
|
account,
|
||||||
SET (sats, storage, paid_to_join, allowed, blocked) = (?, ?, ?, ?, ?)
|
"WHERE relay_id = :relay_id AND pubkey = :pubkey",
|
||||||
WHERE relay_id = ? AND pubkey = ?
|
|
||||||
""",
|
|
||||||
(a.sats, a.storage, a.paid_to_join, a.allowed, a.blocked, relay_id, a.pubkey),
|
|
||||||
)
|
)
|
||||||
|
return account
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_account(relay_id: str, pubkey: str):
|
async def delete_account(relay_id: str, pubkey: str):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM nostrrelay.accounts
|
DELETE FROM nostrrelay.accounts
|
||||||
WHERE relay_id = ? AND pubkey = ?
|
WHERE relay_id = :id AND pubkey = :pubkey
|
||||||
""",
|
""",
|
||||||
(relay_id, pubkey),
|
{"id": relay_id, "pubkey": pubkey},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account(
|
async def get_account(
|
||||||
relay_id: str,
|
relay_id: str,
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
) -> Optional[NostrAccount]:
|
) -> NostrAccount | None:
|
||||||
row = await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND pubkey = ?",
|
"""
|
||||||
(relay_id, pubkey),
|
SELECT * FROM nostrrelay.accounts
|
||||||
|
WHERE relay_id = :id AND pubkey = :pubkey
|
||||||
|
""",
|
||||||
|
{"id": relay_id, "pubkey": pubkey},
|
||||||
|
NostrAccount,
|
||||||
)
|
)
|
||||||
|
|
||||||
return NostrAccount.from_row(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_accounts(
|
async def get_accounts(
|
||||||
relay_id: str,
|
relay_id: str,
|
||||||
allowed=True,
|
allowed=True,
|
||||||
blocked=False,
|
blocked=False,
|
||||||
) -> List[NostrAccount]:
|
) -> list[NostrAccount]:
|
||||||
|
|
||||||
if not allowed and not blocked:
|
if not allowed and not blocked:
|
||||||
return []
|
return []
|
||||||
|
return await db.fetchall(
|
||||||
rows = await db.fetchall(
|
"""
|
||||||
"SELECT * FROM nostrrelay.accounts WHERE relay_id = ? AND allowed = ? OR blocked = ?",
|
SELECT * FROM nostrrelay.accounts
|
||||||
(relay_id, allowed, blocked),
|
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]
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
Create a Nostr relay in just 2 steps!
|
Create a Nostr relay in just 2 steps!
|
||||||
|
|
||||||
Optional settings include:
|
Optional settings include:
|
||||||
* Charging for storage
|
|
||||||
* Charging for joining
|
- Charging for storage
|
||||||
* Npub allow/ban list (for restricting access)
|
- Charging for joining
|
||||||
* Pruning and filtering
|
- Npub allow/ban list (for restricting access)
|
||||||
|
- Pruning and filtering
|
||||||
|
|
|
||||||
34
models.py
34
models.py
|
|
@ -1,6 +1,3 @@
|
||||||
from sqlite3 import Row
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,26 +5,27 @@ class BuyOrder(BaseModel):
|
||||||
action: str
|
action: str
|
||||||
relay_id: str
|
relay_id: str
|
||||||
pubkey: 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"]
|
return self.action in ["join", "storage"]
|
||||||
|
|
||||||
|
|
||||||
class NostrPartialAccount(BaseModel):
|
class NostrPartialAccount(BaseModel):
|
||||||
relay_id: str
|
relay_id: str
|
||||||
pubkey: str
|
pubkey: str
|
||||||
allowed: Optional[bool]
|
allowed: bool | None = None
|
||||||
blocked: Optional[bool]
|
blocked: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class NostrAccount(BaseModel):
|
class NostrAccount(BaseModel):
|
||||||
pubkey: str
|
pubkey: str
|
||||||
allowed = False
|
relay_id: str
|
||||||
blocked = False
|
sats: int = 0
|
||||||
sats = 0
|
storage: int = 0
|
||||||
storage = 0
|
paid_to_join: bool = False
|
||||||
paid_to_join = False
|
allowed: bool = False
|
||||||
|
blocked: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_join(self):
|
def can_join(self):
|
||||||
|
|
@ -36,8 +34,12 @@ class NostrAccount(BaseModel):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def null_account(cls) -> "NostrAccount":
|
def null_account(cls) -> "NostrAccount":
|
||||||
return NostrAccount(pubkey="")
|
return NostrAccount(pubkey="", relay_id="")
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_row(cls, row: Row) -> "NostrAccount":
|
class NostrEventTags(BaseModel):
|
||||||
return cls(**dict(row))
|
relay_id: str
|
||||||
|
event_id: str
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
extra: str | None = None
|
||||||
|
|
|
||||||
59
package-lock.json
generated
Normal file
59
package-lock.json
generated
Normal 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
15
package.json
Normal 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
97
pyproject.toml
Normal 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",
|
||||||
|
]
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import json
|
import json
|
||||||
import time
|
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 fastapi import WebSocket
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
NostrAccount,
|
NostrAccount,
|
||||||
|
|
@ -26,17 +26,17 @@ class NostrClientConnection:
|
||||||
def __init__(self, relay_id: str, websocket: WebSocket):
|
def __init__(self, relay_id: str, websocket: WebSocket):
|
||||||
self.websocket = websocket
|
self.websocket = websocket
|
||||||
self.relay_id = relay_id
|
self.relay_id = relay_id
|
||||||
self.filters: List[NostrFilter] = []
|
self.filters: list[NostrFilter] = []
|
||||||
self.auth_pubkey: Optional[str] = None # set if authenticated
|
self.auth_pubkey: str | None = None # set if authenticated
|
||||||
self._auth_challenge: Optional[str] = None
|
self._auth_challenge: str | None = None
|
||||||
self._auth_challenge_created_at = 0
|
self._auth_challenge_created_at = 0
|
||||||
|
|
||||||
self.event_validator = EventValidator(self.relay_id)
|
self.event_validator = EventValidator(self.relay_id)
|
||||||
|
|
||||||
self.broadcast_event: Optional[
|
self.broadcast_event: (
|
||||||
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]
|
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] | None
|
||||||
] = None
|
) = None
|
||||||
self.get_client_config: Optional[Callable[[], RelaySpec]] = None
|
self.get_client_config: Callable[[], RelaySpec] | None = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
await self.websocket.accept()
|
await self.websocket.accept()
|
||||||
|
|
@ -51,30 +51,30 @@ class NostrClientConnection:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(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"
|
message = reason if reason else "Server closed webocket"
|
||||||
try:
|
try:
|
||||||
await self._send_msg(["NOTICE", message])
|
await self._send_msg(["NOTICE", message])
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.websocket.close(reason=reason)
|
await self.websocket.close(reason=reason)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable):
|
def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable):
|
||||||
setattr(self, "broadcast_event", broadcast_event)
|
self.broadcast_event = broadcast_event
|
||||||
setattr(self, "get_client_config", get_client_config)
|
self.get_client_config = get_client_config
|
||||||
setattr(self.event_validator, "get_client_config", get_client_config)
|
self.event_validator.get_client_config = get_client_config
|
||||||
|
|
||||||
async def notify_event(self, event: NostrEvent) -> bool:
|
async def notify_event(self, event: NostrEvent) -> bool:
|
||||||
if self._is_direct_message_for_other(event):
|
if self._is_direct_message_for_other(event):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for filter in self.filters:
|
for nostr_filter in self.filters:
|
||||||
if filter.matches(event):
|
if nostr_filter.matches(event):
|
||||||
resp = event.serialize_response(filter.subscription_id)
|
resp = event.serialize_response(nostr_filter.subscription_id)
|
||||||
await self._send_msg(resp)
|
await self._send_msg(resp)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
@ -82,7 +82,8 @@ class NostrClientConnection:
|
||||||
def _is_direct_message_for_other(self, event: NostrEvent) -> bool:
|
def _is_direct_message_for_other(self, event: NostrEvent) -> bool:
|
||||||
"""
|
"""
|
||||||
Direct messages are not inteded to be boradcast (even if encrypted).
|
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:
|
if not event.is_direct_message:
|
||||||
return False
|
return False
|
||||||
|
|
@ -98,18 +99,34 @@ class NostrClientConnection:
|
||||||
if self.broadcast_event:
|
if self.broadcast_event:
|
||||||
await self.broadcast_event(self, e)
|
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:
|
if len(data) < 2:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
message_type = data[0]
|
message_type = data[0]
|
||||||
|
|
||||||
if message_type == NostrEventType.EVENT:
|
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 []
|
return []
|
||||||
if message_type == NostrEventType.REQ:
|
if message_type == NostrEventType.REQ:
|
||||||
if len(data) != 3:
|
if len(data) < 3:
|
||||||
return []
|
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:
|
if message_type == NostrEventType.CLOSE:
|
||||||
self._handle_close(data[1])
|
self._handle_close(data[1])
|
||||||
if message_type == NostrEventType.AUTH:
|
if message_type == NostrEventType.AUTH:
|
||||||
|
|
@ -119,7 +136,7 @@ class NostrClientConnection:
|
||||||
|
|
||||||
async def _handle_event(self, e: NostrEvent):
|
async def _handle_event(self, e: NostrEvent):
|
||||||
logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']")
|
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:
|
if e.is_auth_response_event:
|
||||||
valid, message = self.event_validator.validate_auth_event(
|
valid, message = self.event_validator.validate_auth_event(
|
||||||
|
|
@ -130,13 +147,12 @@ class NostrClientConnection:
|
||||||
await self._send_msg(resp_nip20)
|
await self._send_msg(resp_nip20)
|
||||||
return None
|
return None
|
||||||
self.auth_pubkey = e.pubkey
|
self.auth_pubkey = e.pubkey
|
||||||
return None
|
|
||||||
|
|
||||||
if not self.auth_pubkey and self.config.event_requires_auth(e.kind):
|
if not self.auth_pubkey and self.config.event_requires_auth(e.kind):
|
||||||
await self._send_msg(["AUTH", self._current_auth_challenge()])
|
await self._send_msg(["AUTH", self._current_auth_challenge()])
|
||||||
resp_nip20 += [
|
resp_nip20 += [
|
||||||
False,
|
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)
|
await self._send_msg(resp_nip20)
|
||||||
return None
|
return None
|
||||||
|
|
@ -147,15 +163,27 @@ class NostrClientConnection:
|
||||||
resp_nip20 += [valid, message]
|
resp_nip20 += [valid, message]
|
||||||
await self._send_msg(resp_nip20)
|
await self._send_msg(resp_nip20)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if e.is_replaceable_event:
|
if e.is_replaceable_event:
|
||||||
await delete_events(
|
await delete_events(
|
||||||
self.relay_id,
|
self.relay_id,
|
||||||
NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at),
|
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:
|
if not e.is_ephemeral_event:
|
||||||
await create_event(self.relay_id, e, self.auth_pubkey)
|
await create_event(e)
|
||||||
await self._broadcast_event(e)
|
await self._broadcast_event(e)
|
||||||
|
|
||||||
if e.is_delete_event:
|
if e.is_delete_event:
|
||||||
|
|
@ -166,7 +194,7 @@ class NostrClientConnection:
|
||||||
event = await get_event(self.relay_id, e.id)
|
event = await get_event(self.relay_id, e.id)
|
||||||
# todo: handle NIP20 in detail
|
# todo: handle NIP20 in detail
|
||||||
message = "error: failed to create event"
|
message = "error: failed to create event"
|
||||||
resp_nip20 += [event != None, message]
|
resp_nip20 += [event is not None, message]
|
||||||
|
|
||||||
await self._send_msg(resp_nip20)
|
await self._send_msg(resp_nip20)
|
||||||
|
|
||||||
|
|
@ -176,18 +204,20 @@ class NostrClientConnection:
|
||||||
raise Exception("Client not ready!")
|
raise Exception("Client not ready!")
|
||||||
return self.get_client_config()
|
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))
|
await self.websocket.send_text(json.dumps(data))
|
||||||
|
|
||||||
async def _handle_delete_event(self, event: NostrEvent):
|
async def _handle_delete_event(self, event: NostrEvent):
|
||||||
# NIP 09
|
# NIP 09
|
||||||
filter = NostrFilter(authors=[event.pubkey])
|
nostr_filter = NostrFilter(authors=[event.pubkey])
|
||||||
filter.ids = [t[1] for t in event.tags if t[0] == "e"]
|
nostr_filter.ids = [t[1] for t in event.tags if t[0] == "e"]
|
||||||
events_to_delete = await get_events(self.relay_id, filter, False)
|
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]
|
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))
|
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 self.config.require_auth_filter:
|
||||||
if not self.auth_pubkey:
|
if not self.auth_pubkey:
|
||||||
return [["AUTH", self._current_auth_challenge()]]
|
return [["AUTH", self._current_auth_challenge()]]
|
||||||
|
|
@ -199,26 +229,30 @@ class NostrClientConnection:
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
"NOTICE",
|
"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:
|
if not account.can_join and not self.config.is_free_to_join:
|
||||||
return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]]
|
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)
|
self._remove_filter(subscription_id)
|
||||||
if self._can_add_filter():
|
if self._can_add_filter():
|
||||||
|
max_filters = self.config.max_client_filters
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
"NOTICE",
|
"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)
|
nostr_filter.enforce_limit(self.config.limit_per_filter)
|
||||||
self.filters.append(filter)
|
self.filters.append(nostr_filter)
|
||||||
events = await get_events(self.relay_id, 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)]
|
events = [e for e in events if not self._is_direct_message_for_other(e)]
|
||||||
serialized_events = [
|
serialized_events = [
|
||||||
event.serialize_response(subscription_id) for event in events
|
event.serialize_response(subscription_id) for event in events
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ..crud import get_config_for_all_active_relays
|
from ..crud import get_config_for_all_active_relays
|
||||||
from .client_connection import NostrClientConnection
|
from .client_connection import NostrClientConnection
|
||||||
from .event import NostrEvent
|
from .event import NostrEvent
|
||||||
|
|
@ -47,7 +45,7 @@ class NostrClientManager:
|
||||||
def get_relay_config(self, relay_id: str) -> RelaySpec:
|
def get_relay_config(self, relay_id: str) -> RelaySpec:
|
||||||
return self._active_relays[relay_id]
|
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:
|
if relay_id not in self._clients:
|
||||||
self._clients[relay_id] = []
|
self._clients[relay_id] = []
|
||||||
return self._clients[relay_id]
|
return self._clients[relay_id]
|
||||||
|
|
@ -71,5 +69,5 @@ class NostrClientManager:
|
||||||
def get_client_config() -> RelaySpec:
|
def get_client_config() -> RelaySpec:
|
||||||
return self.get_relay_config(client.relay_id)
|
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)
|
client.init_callbacks(self.broadcast_event, get_client_config)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlite3 import Row
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from secp256k1 import PublicKey
|
from secp256k1 import PublicKey
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,14 +15,22 @@ class NostrEventType(str, Enum):
|
||||||
|
|
||||||
class NostrEvent(BaseModel):
|
class NostrEvent(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
relay_id: str
|
||||||
|
publisher: str
|
||||||
pubkey: str
|
pubkey: str
|
||||||
created_at: int
|
created_at: int
|
||||||
kind: int
|
kind: int
|
||||||
tags: List[List[str]] = []
|
tags: list[list[str]] = Field(default=[], no_database=True)
|
||||||
content: str = ""
|
content: str = ""
|
||||||
sig: 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]
|
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
||||||
|
|
||||||
def serialize_json(self) -> str:
|
def serialize_json(self) -> str:
|
||||||
|
|
@ -34,12 +40,11 @@ class NostrEvent(BaseModel):
|
||||||
@property
|
@property
|
||||||
def event_id(self) -> str:
|
def event_id(self) -> str:
|
||||||
data = self.serialize_json()
|
data = self.serialize_json()
|
||||||
id = hashlib.sha256(data.encode()).hexdigest()
|
return hashlib.sha256(data.encode()).hexdigest()
|
||||||
return id
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size_bytes(self) -> int:
|
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())
|
return len(s.encode())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -66,6 +71,10 @@ class NostrEvent(BaseModel):
|
||||||
def is_ephemeral_event(self) -> bool:
|
def is_ephemeral_event(self) -> bool:
|
||||||
return self.kind >= 20000 and self.kind < 30000
|
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):
|
def check_signature(self):
|
||||||
event_id = self.event_id
|
event_id = self.event_id
|
||||||
if self.id != event_id:
|
if self.id != event_id:
|
||||||
|
|
@ -74,10 +83,10 @@ class NostrEvent(BaseModel):
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
|
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
valid_signature = pub_key.schnorr_verify(
|
valid_signature = pub_key.schnorr_verify(
|
||||||
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
|
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}'")
|
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
|
||||||
|
|
||||||
def serialize_response(self, subscription_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]
|
return [t[1] for t in self.tags if t[0] == tag_name]
|
||||||
|
|
||||||
def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
|
def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
|
||||||
|
|
@ -96,7 +105,3 @@ class NostrEvent(BaseModel):
|
||||||
|
|
||||||
def is_direct_message_for_pubkey(self, pubkey: str) -> bool:
|
def is_direct_message_for_pubkey(self, pubkey: str) -> bool:
|
||||||
return self.is_direct_message and self.has_tag_value("p", pubkey)
|
return self.is_direct_message and self.has_tag_value("p", pubkey)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_row(cls, row: Row) -> "NostrEvent":
|
|
||||||
return cls(**dict(row))
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import time
|
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 ..crud import get_account, get_storage_for_public_key, prune_old_events
|
||||||
from ..helpers import extract_domain
|
from ..helpers import extract_domain
|
||||||
|
|
@ -15,11 +15,11 @@ class EventValidator:
|
||||||
self._last_event_timestamp = 0 # in hours
|
self._last_event_timestamp = 0 # in hours
|
||||||
self._event_count_per_timestamp = 0
|
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(
|
async def validate_write(
|
||||||
self, e: NostrEvent, publisher_pubkey: str
|
self, e: NostrEvent, publisher_pubkey: str
|
||||||
) -> Tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
valid, message = self._validate_event(e)
|
valid, message = self._validate_event(e)
|
||||||
if not valid:
|
if not valid:
|
||||||
return (valid, message)
|
return (valid, message)
|
||||||
|
|
@ -34,8 +34,8 @@ class EventValidator:
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def validate_auth_event(
|
def validate_auth_event(
|
||||||
self, e: NostrEvent, auth_challenge: Optional[str]
|
self, e: NostrEvent, auth_challenge: str | None
|
||||||
) -> Tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
valid, message = self._validate_event(e)
|
valid, message = self._validate_event(e)
|
||||||
if not valid:
|
if not valid:
|
||||||
return (valid, message)
|
return (valid, message)
|
||||||
|
|
@ -59,9 +59,9 @@ class EventValidator:
|
||||||
raise Exception("EventValidator not ready!")
|
raise Exception("EventValidator not ready!")
|
||||||
return self.get_client_config()
|
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():
|
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:
|
try:
|
||||||
e.check_signature()
|
e.check_signature()
|
||||||
|
|
@ -76,7 +76,7 @@ class EventValidator:
|
||||||
|
|
||||||
async def _validate_storage(
|
async def _validate_storage(
|
||||||
self, pubkey: str, event_size_bytes: int
|
self, pubkey: str, event_size_bytes: int
|
||||||
) -> Tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
if self.config.is_read_only_relay:
|
if self.config.is_read_only_relay:
|
||||||
return False, "Cannot write event, relay is read-only"
|
return False, "Cannot write event, relay is read-only"
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ class EventValidator:
|
||||||
if self.config.full_storage_action == "block":
|
if self.config.full_storage_action == "block":
|
||||||
return (
|
return (
|
||||||
False,
|
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:
|
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
|
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())
|
current_time = round(time.time())
|
||||||
if self.config.created_at_in_past != 0:
|
if self.config.created_at_in_past != 0:
|
||||||
if created_at < (current_time - self.config.created_at_in_past):
|
if created_at < (current_time - self.config.created_at_in_past):
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
from typing import Any, List, Optional, Tuple
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .event import NostrEvent
|
from .event import NostrEvent
|
||||||
|
|
||||||
|
|
||||||
class NostrFilter(BaseModel):
|
class NostrFilter(BaseModel):
|
||||||
subscription_id: Optional[str]
|
e: list[str] = Field(default=[], alias="#e")
|
||||||
|
p: list[str] = Field(default=[], alias="#p")
|
||||||
ids: List[str] = []
|
d: list[str] = Field(default=[], alias="#d")
|
||||||
authors: List[str] = []
|
ids: list[str] = []
|
||||||
kinds: List[int] = []
|
authors: list[str] = []
|
||||||
e: List[str] = Field([], alias="#e")
|
kinds: list[int] = []
|
||||||
p: List[str] = Field([], alias="#p")
|
subscription_id: str | None = None
|
||||||
since: Optional[int]
|
since: int | None = None
|
||||||
until: Optional[int]
|
until: int | None = None
|
||||||
limit: Optional[int]
|
limit: int | None = None
|
||||||
|
|
||||||
def matches(self, e: NostrEvent) -> bool:
|
def matches(self, e: NostrEvent) -> bool:
|
||||||
# todo: starts with
|
# todo: starts with
|
||||||
|
|
@ -31,9 +29,12 @@ class NostrFilter(BaseModel):
|
||||||
if self.until and self.until > 0 and e.created_at > self.until:
|
if self.until and self.until > 0 and e.created_at > self.until:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
found_e_tag = self.tag_in_list(e.tags, "e")
|
# Check tag filters - only fail if filter is specified and no match found
|
||||||
found_p_tag = self.tag_in_list(e.tags, "p")
|
if not self.tag_in_list(e.tags, "e"):
|
||||||
if not found_e_tag or not found_p_tag:
|
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 False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
@ -67,50 +68,56 @@ class NostrFilter(BaseModel):
|
||||||
if not self.limit or self.limit > limit:
|
if not self.limit or self.limit > limit:
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
def to_sql_components(
|
def to_sql_components(self, relay_id: str) -> tuple[list[str], list[str], dict]:
|
||||||
self, relay_id: str
|
inner_joins: list[str] = []
|
||||||
) -> Tuple[List[str], List[str], List[Any]]:
|
where = ["deleted=false", "nostrrelay.events.relay_id = :relay_id"]
|
||||||
inner_joins: List[str] = []
|
values: dict = {"relay_id": relay_id}
|
||||||
where = ["deleted=false", "nostrrelay.events.relay_id = ?"]
|
|
||||||
values: List[Any] = [relay_id]
|
|
||||||
|
|
||||||
if len(self.e):
|
if len(self.e):
|
||||||
values += self.e
|
e_s = ",".join([f"'{e}'" for e in self.e])
|
||||||
e_s = ",".join(["?"] * len(self.e))
|
|
||||||
inner_joins.append(
|
inner_joins.append(
|
||||||
"INNER JOIN nostrrelay.event_tags e_tags 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')")
|
where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')")
|
||||||
|
|
||||||
if len(self.p):
|
if len(self.p):
|
||||||
values += self.p
|
p_s = ",".join([f"'{p}'" for p in self.p])
|
||||||
p_s = ",".join(["?"] * len(self.p))
|
|
||||||
inner_joins.append(
|
inner_joins.append(
|
||||||
"INNER JOIN nostrrelay.event_tags p_tags 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'")
|
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:
|
if len(self.ids) != 0:
|
||||||
ids = ",".join(["?"] * len(self.ids))
|
ids = ",".join([f"'{_id}'" for _id in self.ids])
|
||||||
where.append(f"id IN ({ids})")
|
where.append(f"id IN ({ids})")
|
||||||
values += self.ids
|
|
||||||
|
|
||||||
if len(self.authors) != 0:
|
if len(self.authors) != 0:
|
||||||
authors = ",".join(["?"] * len(self.authors))
|
authors = ",".join([f"'{author}'" for author in self.authors])
|
||||||
where.append(f"pubkey IN ({authors})")
|
where.append(f"pubkey IN ({authors})")
|
||||||
values += self.authors
|
|
||||||
|
|
||||||
if len(self.kinds) != 0:
|
if len(self.kinds) != 0:
|
||||||
kinds = ",".join(["?"] * len(self.kinds))
|
kinds = ",".join([f"'{kind}'" for kind in self.kinds])
|
||||||
where.append(f"kind IN ({kinds})")
|
where.append(f"kind IN ({kinds})")
|
||||||
values += self.kinds
|
|
||||||
|
|
||||||
if self.since:
|
if self.since:
|
||||||
where.append("created_at >= ?")
|
where.append("created_at >= :since")
|
||||||
values += [self.since]
|
values["since"] = self.since
|
||||||
|
|
||||||
if self.until:
|
if self.until:
|
||||||
where.append("created_at < ?")
|
where.append("created_at < :until")
|
||||||
values += [self.until]
|
values["until"] = self.until
|
||||||
|
|
||||||
return inner_joins, where, values
|
return inner_joins, where, values
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
import json
|
|
||||||
from sqlite3 import Row
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -11,22 +7,22 @@ class Spec(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class FilterSpec(Spec):
|
class FilterSpec(Spec):
|
||||||
max_client_filters = Field(0, alias="maxClientFilters")
|
max_client_filters: int = Field(default=0, alias="maxClientFilters")
|
||||||
limit_per_filter = Field(1000, alias="limitPerFilter")
|
limit_per_filter: int = Field(default=1000, alias="limitPerFilter")
|
||||||
|
|
||||||
|
|
||||||
class EventSpec(Spec):
|
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_days_past: int = Field(default=0, alias="createdAtDaysPast")
|
||||||
created_at_hours_past = Field(0, alias="createdAtHoursPast")
|
created_at_hours_past: int = Field(default=0, alias="createdAtHoursPast")
|
||||||
created_at_minutes_past = Field(0, alias="createdAtMinutesPast")
|
created_at_minutes_past: int = Field(default=0, alias="createdAtMinutesPast")
|
||||||
created_at_seconds_past = Field(0, alias="createdAtSecondsPast")
|
created_at_seconds_past: int = Field(default=0, alias="createdAtSecondsPast")
|
||||||
|
|
||||||
created_at_days_future = Field(0, alias="createdAtDaysFuture")
|
created_at_days_future: int = Field(default=0, alias="createdAtDaysFuture")
|
||||||
created_at_hours_future = Field(0, alias="createdAtHoursFuture")
|
created_at_hours_future: int = Field(default=0, alias="createdAtHoursFuture")
|
||||||
created_at_minutes_future = Field(0, alias="createdAtMinutesFuture")
|
created_at_minutes_future: int = Field(default=0, alias="createdAtMinutesFuture")
|
||||||
created_at_seconds_future = Field(0, alias="createdAtSecondsFuture")
|
created_at_seconds_future: int = Field(default=0, alias="createdAtSecondsFuture")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created_at_in_past(self) -> int:
|
def created_at_in_past(self) -> int:
|
||||||
|
|
@ -48,9 +44,9 @@ class EventSpec(Spec):
|
||||||
|
|
||||||
|
|
||||||
class StorageSpec(Spec):
|
class StorageSpec(Spec):
|
||||||
free_storage_value = Field(1, alias="freeStorageValue")
|
free_storage_value: int = Field(default=1, alias="freeStorageValue")
|
||||||
free_storage_unit = Field("MB", alias="freeStorageUnit")
|
free_storage_unit: str = Field(default="MB", alias="freeStorageUnit")
|
||||||
full_storage_action = Field("prune", alias="fullStorageAction")
|
full_storage_action: str = Field(default="prune", alias="fullStorageAction")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def free_storage_bytes_value(self):
|
def free_storage_bytes_value(self):
|
||||||
|
|
@ -61,10 +57,10 @@ class StorageSpec(Spec):
|
||||||
|
|
||||||
|
|
||||||
class AuthSpec(Spec):
|
class AuthSpec(Spec):
|
||||||
require_auth_events = Field(False, alias="requireAuthEvents")
|
require_auth_events: bool = Field(default=False, alias="requireAuthEvents")
|
||||||
skiped_auth_events = Field([], alias="skipedAuthEvents")
|
skiped_auth_events: list = Field(default=[], alias="skipedAuthEvents")
|
||||||
forced_auth_events = Field([], alias="forcedAuthEvents")
|
forced_auth_events: list = Field(default=[], alias="forcedAuthEvents")
|
||||||
require_auth_filter = Field(False, alias="requireAuthFilter")
|
require_auth_filter: bool = Field(default=False, alias="requireAuthFilter")
|
||||||
|
|
||||||
def event_requires_auth(self, kind: int) -> bool:
|
def event_requires_auth(self, kind: int) -> bool:
|
||||||
if self.require_auth_events:
|
if self.require_auth_events:
|
||||||
|
|
@ -73,11 +69,11 @@ class AuthSpec(Spec):
|
||||||
|
|
||||||
|
|
||||||
class PaymentSpec(Spec):
|
class PaymentSpec(Spec):
|
||||||
is_paid_relay = Field(False, alias="isPaidRelay")
|
is_paid_relay: bool = Field(default=False, alias="isPaidRelay")
|
||||||
cost_to_join = Field(0, alias="costToJoin")
|
cost_to_join: int = Field(default=0, alias="costToJoin")
|
||||||
|
|
||||||
storage_cost_value = Field(0, alias="storageCostValue")
|
storage_cost_value: int = Field(default=0, alias="storageCostValue")
|
||||||
storage_cost_unit = Field("MB", alias="storageCostUnit")
|
storage_cost_unit: str = Field(default="MB", alias="storageCostUnit")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_free_to_join(self):
|
def is_free_to_join(self):
|
||||||
|
|
@ -85,7 +81,7 @@ class PaymentSpec(Spec):
|
||||||
|
|
||||||
|
|
||||||
class WalletSpec(Spec):
|
class WalletSpec(Spec):
|
||||||
wallet = Field("")
|
wallet: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
|
class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
|
||||||
|
|
@ -93,7 +89,7 @@ class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_read_only_relay(self):
|
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):
|
class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
|
||||||
|
|
@ -102,23 +98,17 @@ class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
|
||||||
|
|
||||||
class NostrRelay(BaseModel):
|
class NostrRelay(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
user_id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str]
|
description: str | None = None
|
||||||
pubkey: Optional[str]
|
pubkey: str | None = None
|
||||||
contact: Optional[str]
|
contact: str | None = None
|
||||||
active: bool = False
|
active: bool = False
|
||||||
|
meta: RelaySpec = RelaySpec()
|
||||||
config: "RelaySpec" = RelaySpec()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_free_to_join(self):
|
def is_free_to_join(self):
|
||||||
return not self.config.is_paid_relay or self.config.cost_to_join == 0
|
return not self.meta.is_paid_relay or self.meta.cost_to_join == 0
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_row(cls, row: Row) -> "NostrRelay":
|
|
||||||
relay = cls(**dict(row))
|
|
||||||
relay.config = RelaySpec(**json.loads(row["meta"]))
|
|
||||||
return relay
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def info(
|
def info(
|
||||||
|
|
|
||||||
290
static/components/relay-details.js
Normal file
290
static/components/relay-details.js
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +1,13 @@
|
||||||
const relays = async () => {
|
window.app = Vue.createApp({
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
await relayDetails('static/components/relay-details/relay-details.html')
|
data() {
|
||||||
|
return {
|
||||||
new Vue({
|
filter: '',
|
||||||
el: '#vue',
|
relayLinks: [],
|
||||||
mixins: [windowMixin],
|
formDialogRelay: {
|
||||||
data: function () {
|
show: false,
|
||||||
return {
|
data: {
|
||||||
filter: '',
|
|
||||||
relayLinks: [],
|
|
||||||
formDialogRelay: {
|
|
||||||
show: false,
|
|
||||||
data: {
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
pubkey: '',
|
|
||||||
contact: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
relaysTable: {
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: '',
|
|
||||||
align: 'left',
|
|
||||||
label: '',
|
|
||||||
field: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'id',
|
|
||||||
align: 'left',
|
|
||||||
label: 'ID',
|
|
||||||
field: 'id'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'toggle',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Active',
|
|
||||||
field: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Name',
|
|
||||||
field: 'name'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'description',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Description',
|
|
||||||
field: 'description'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'pubkey',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Public Key',
|
|
||||||
field: 'pubkey'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'contact',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Contact',
|
|
||||||
field: 'contact'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getDefaultRelayData: function () {
|
|
||||||
return {
|
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
|
@ -83,99 +16,158 @@ const relays = async () => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
openCreateRelayDialog: function () {
|
relaysTable: {
|
||||||
this.formDialogRelay.data = this.getDefaultRelayData()
|
columns: [
|
||||||
this.formDialogRelay.show = true
|
{
|
||||||
},
|
name: '',
|
||||||
getRelays: async function () {
|
align: 'left',
|
||||||
try {
|
label: '',
|
||||||
const {data} = await LNbits.api.request(
|
field: ''
|
||||||
'GET',
|
},
|
||||||
'/nostrrelay/api/v1/relay',
|
{
|
||||||
this.g.user.wallets[0].inkey
|
name: 'id',
|
||||||
)
|
align: 'left',
|
||||||
this.relayLinks = data.map(c =>
|
label: 'ID',
|
||||||
mapRelay(
|
field: 'id'
|
||||||
c,
|
},
|
||||||
this.relayLinks.find(old => old.id === c.id)
|
{
|
||||||
)
|
name: 'toggle',
|
||||||
)
|
align: 'left',
|
||||||
} catch (error) {
|
label: 'Active',
|
||||||
LNbits.utils.notifyApiError(error)
|
field: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Name',
|
||||||
|
field: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Description',
|
||||||
|
field: 'description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pubkey',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Public Key',
|
||||||
|
field: 'pubkey'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'contact',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Contact',
|
||||||
|
field: 'contact'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
createRelay: async function (data) {
|
},
|
||||||
try {
|
methods: {
|
||||||
const resp = await LNbits.api.request(
|
getDefaultRelayData: function () {
|
||||||
'POST',
|
return {
|
||||||
'/nostrrelay/api/v1/relay',
|
id: '',
|
||||||
this.g.user.wallets[0].adminkey,
|
name: '',
|
||||||
data
|
description: '',
|
||||||
)
|
pubkey: '',
|
||||||
|
contact: ''
|
||||||
this.relayLinks.unshift(mapRelay(resp.data))
|
|
||||||
this.formDialogRelay.show = false
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showToggleRelayDialog: function (relay) {
|
|
||||||
if (relay.active) {
|
|
||||||
this.toggleRelay(relay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog('Are you sure you want to deactivate this relay?')
|
|
||||||
.onOk(async () => {
|
|
||||||
this.toggleRelay(relay)
|
|
||||||
})
|
|
||||||
.onCancel(async () => {
|
|
||||||
relay.active = !relay.active
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleRelay: async function (relay) {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
'/nostrrelay/api/v1/relay/' + relay.id,
|
|
||||||
this.g.user.wallets[0].adminkey,
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
sendFormDataRelay: async function () {
|
|
||||||
this.createRelay(this.formDialogRelay.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
handleRelayDeleted: function (relayId) {
|
|
||||||
this.relayLinks = _.reject(this.relayLinks, function (obj) {
|
|
||||||
return obj.id === relayId
|
|
||||||
})
|
|
||||||
},
|
|
||||||
handleRelayUpdated: function (relay) {
|
|
||||||
const index = this.relayLinks.findIndex(r => r.id === relay.id)
|
|
||||||
if (index !== -1) {
|
|
||||||
relay.expanded = true
|
|
||||||
this.relayLinks.splice(index, 1, relay)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
exportrelayCSV: function () {
|
|
||||||
LNbits.utils.exportCSV(
|
|
||||||
this.relaysTable.columns,
|
|
||||||
this.relayLinks,
|
|
||||||
'relays'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: async function () {
|
|
||||||
await this.getRelays()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
relays()
|
openCreateRelayDialog: function () {
|
||||||
|
this.formDialogRelay.data = this.getDefaultRelayData()
|
||||||
|
this.formDialogRelay.show = true
|
||||||
|
},
|
||||||
|
getRelays: async function () {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrrelay/api/v1/relay',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
this.relayLinks = data.map(c =>
|
||||||
|
mapRelay(
|
||||||
|
c,
|
||||||
|
this.relayLinks.find(old => old.id === c.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createRelay: async function (data) {
|
||||||
|
try {
|
||||||
|
const resp = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/nostrrelay/api/v1/relay',
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
this.relayLinks.unshift(mapRelay(resp.data))
|
||||||
|
this.formDialogRelay.show = false
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToggleRelayDialog: function (relay) {
|
||||||
|
if (relay.active) {
|
||||||
|
this.toggleRelay(relay)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to deactivate this relay?')
|
||||||
|
.onOk(async () => {
|
||||||
|
this.toggleRelay(relay)
|
||||||
|
})
|
||||||
|
.onCancel(async () => {
|
||||||
|
relay.active = !relay.active
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleRelay: async function (relay) {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/nostrrelay/api/v1/relay/' + relay.id,
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendFormDataRelay: async function () {
|
||||||
|
this.createRelay(this.formDialogRelay.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRelayDeleted: function (relayId) {
|
||||||
|
this.relayLinks = _.reject(this.relayLinks, function (obj) {
|
||||||
|
return obj.id === relayId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleRelayUpdated: function (relay) {
|
||||||
|
const index = this.relayLinks.findIndex(r => r.id === relay.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
relay.expanded = true
|
||||||
|
this.relayLinks.splice(index, 1, relay)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportrelayCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(
|
||||||
|
this.relaysTable.columns,
|
||||||
|
this.relayLinks,
|
||||||
|
'relays'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {
|
||||||
|
await this.getRelays()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,3 @@ const mapRelay = (obj, oldObj = {}) => {
|
||||||
|
|
||||||
return relay
|
return relay
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTemplateAsync(path) {
|
|
||||||
const result = new Promise(resolve => {
|
|
||||||
const xhttp = new XMLHttpRequest()
|
|
||||||
|
|
||||||
xhttp.onreadystatechange = function () {
|
|
||||||
if (this.readyState == 4) {
|
|
||||||
if (this.status == 200) resolve(this.responseText)
|
|
||||||
|
|
||||||
if (this.status == 404) resolve(`<div>Page not found: ${path}</div>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xhttp.open('GET', path, true)
|
|
||||||
xhttp.send()
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
|
||||||
82
tasks.py
82
tasks.py
|
|
@ -1,12 +1,10 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
from lnbits.core.services import websocket_updater
|
from lnbits.core.services import websocket_updater
|
||||||
from lnbits.helpers import get_current_extension_name
|
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import create_account, get_account, update_account
|
from .crud import create_account, get_account, update_account
|
||||||
from .models import NostrAccount
|
from .models import NostrAccount
|
||||||
|
|
@ -14,7 +12,7 @@ from .models import NostrAccount
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
invoice_queue = asyncio.Queue()
|
invoice_queue = asyncio.Queue()
|
||||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
register_invoice_listener(invoice_queue, "ext_nostrrelay")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
payment = await invoice_queue.get()
|
payment = await invoice_queue.get()
|
||||||
|
|
@ -27,75 +25,75 @@ async def on_invoice_paid(payment: Payment):
|
||||||
|
|
||||||
relay_id = payment.extra.get("relay_id")
|
relay_id = payment.extra.get("relay_id")
|
||||||
pubkey = payment.extra.get("pubkey")
|
pubkey = payment.extra.get("pubkey")
|
||||||
hash = payment.payment_hash
|
payment_hash = payment.payment_hash
|
||||||
|
|
||||||
if not relay_id or not pubkey:
|
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)
|
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
|
return
|
||||||
|
|
||||||
action = payment.extra.get("action")
|
action = payment.extra.get("action")
|
||||||
if action == "join":
|
if action == "join":
|
||||||
await invoice_paid_to_join(relay_id, pubkey, payment.amount)
|
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
|
return
|
||||||
|
|
||||||
if action == "storage":
|
if action == "storage":
|
||||||
storage_to_buy = payment.extra.get("storage_to_buy")
|
storage_to_buy = payment.extra.get("storage_to_buy")
|
||||||
if not storage_to_buy:
|
if not storage_to_buy:
|
||||||
message = (
|
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)
|
logger.warning(message)
|
||||||
return
|
return
|
||||||
await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount)
|
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
|
return
|
||||||
|
|
||||||
await websocket_updater(
|
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):
|
async def invoice_paid_to_join(relay_id: str, pubkey: str, amount: int):
|
||||||
try:
|
account = await get_account(relay_id, pubkey)
|
||||||
account = await get_account(relay_id, pubkey)
|
if not account:
|
||||||
if not account:
|
account = NostrAccount(
|
||||||
await create_account(
|
relay_id=relay_id, pubkey=pubkey, paid_to_join=True, sats=amount
|
||||||
relay_id, NostrAccount(pubkey=pubkey, paid_to_join=True, sats=amount)
|
)
|
||||||
)
|
await create_account(account)
|
||||||
return
|
return
|
||||||
|
|
||||||
if account.blocked or account.paid_to_join:
|
if account.blocked or account.paid_to_join:
|
||||||
return
|
return
|
||||||
|
|
||||||
account.paid_to_join = True
|
account.paid_to_join = True
|
||||||
account.sats += amount
|
account.sats += amount
|
||||||
await update_account(relay_id, account)
|
await update_account(account)
|
||||||
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
|
|
||||||
|
|
||||||
async def invoice_paid_for_storage(
|
async def invoice_paid_for_storage(
|
||||||
relay_id: str, pubkey: str, storage_to_buy: int, amount: int
|
relay_id: str, pubkey: str, storage_to_buy: int, amount: int
|
||||||
):
|
):
|
||||||
try:
|
account = await get_account(relay_id, pubkey)
|
||||||
account = await get_account(relay_id, pubkey)
|
if not account:
|
||||||
if not account:
|
account = NostrAccount(
|
||||||
await create_account(
|
relay_id=relay_id, pubkey=pubkey, storage=storage_to_buy, sats=amount
|
||||||
relay_id,
|
)
|
||||||
NostrAccount(pubkey=pubkey, storage=storage_to_buy, sats=amount),
|
await create_account(account)
|
||||||
)
|
return
|
||||||
return
|
|
||||||
|
|
||||||
if account.blocked:
|
if account.blocked:
|
||||||
return
|
return
|
||||||
|
|
||||||
account.storage = storage_to_buy
|
account.storage = storage_to_buy
|
||||||
account.sats += amount
|
account.sats += amount
|
||||||
await update_account(relay_id, account)
|
await update_account(account)
|
||||||
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
%} {% block page %}
|
%} {% block page %}
|
||||||
|
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
{% raw %}
|
|
||||||
<q-btn unelevated color="primary" @click="openCreateRelayDialog()"
|
<q-btn unelevated color="primary" @click="openCreateRelayDialog()"
|
||||||
>New relay
|
>New relay
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -49,10 +49,10 @@
|
||||||
<q-table
|
<q-table
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
:data="relayLinks"
|
:rows="relayLinks"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:columns="relaysTable.columns"
|
:columns="relaysTable.columns"
|
||||||
:pagination.sync="relaysTable.pagination"
|
v-model:pagination="relaysTable.pagination"
|
||||||
:filter="filter"
|
:filter="filter"
|
||||||
>
|
>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
|
|
@ -69,28 +69,37 @@
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
<q-td key="id" :props="props">
|
<q-td key="id" :props="props">
|
||||||
<a style="color: unset" :href="props.row.id" target="_blank">
|
<a
|
||||||
{{props.row.id}}</a
|
style="color: unset"
|
||||||
>
|
:href="props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
v-text="props.row.id"
|
||||||
|
></a>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="toggle" :props="props">
|
<q-td key="toggle" :props="props">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secodary"
|
color="secodary"
|
||||||
v-model="props.row.active"
|
v-model="props.row.active"
|
||||||
@input="showToggleRelayDialog(props.row)"
|
@update:model-value="showToggleRelayDialog(props.row)"
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td auto-width> {{props.row.name}} </q-td>
|
<q-td auto-width v-text="props.row.name"></q-td>
|
||||||
<q-td key="description" :props="props">
|
<q-td
|
||||||
{{props.row.description}}
|
key="description"
|
||||||
</q-td>
|
:props="props"
|
||||||
<q-td key="pubkey" :props="props">
|
v-text="props.row.description"
|
||||||
<div>{{props.row.pubkey}}</div>
|
></q-td>
|
||||||
</q-td>
|
<q-td
|
||||||
<q-td key="contact" :props="props">
|
key="pubkey"
|
||||||
<div>{{props.row.contact}}</div>
|
:props="props"
|
||||||
</q-td>
|
v-text="props.row.pubkey"
|
||||||
|
></q-td>
|
||||||
|
<q-td
|
||||||
|
key="contact"
|
||||||
|
:props="props"
|
||||||
|
v-text="props.row.contact"
|
||||||
|
></q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
<q-tr v-if="props.row.expanded" :props="props">
|
<q-tr v-if="props.row.expanded" :props="props">
|
||||||
<q-td colspan="100%">
|
<q-td colspan="100%">
|
||||||
|
|
@ -109,7 +118,6 @@
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
{% endraw %}
|
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -188,9 +196,14 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %} {% block vue_templates %}
|
||||||
|
<template id="relay-details">
|
||||||
|
{% include("nostrrelay/relay-details.html") %}
|
||||||
|
</template>
|
||||||
|
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script src="{{ url_for('nostrrelay_static', path='js/utils.js') }}"></script>
|
<script src="{{ static_url_for('nostrrelay/static', path='js/utils.js') }}"></script>
|
||||||
<script src="{{ url_for('nostrrelay_static', path='components/relay-details/relay-details.js') }}"></script>
|
<script src="{{ static_url_for('nostrrelay/static', path='js/index.js') }}"></script>
|
||||||
<script src="{{ url_for('nostrrelay_static', path='js/index.js') }}"></script>
|
<script src="{{ static_url_for('nostrrelay/static', path='components/relay-details.js') }}"></script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="relay.config.isPaidRelay">
|
<q-card-section v-if="relay.meta.isPaidRelay">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2 q-pt-sm">
|
<div class="col-2 q-pt-sm">
|
||||||
<span class="text-bold">Public Key:</span>
|
<span class="text-bold">Public Key:</span>
|
||||||
|
|
@ -60,19 +60,19 @@
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="relay.config.isPaidRelay">
|
<q-card-section v-if="relay.meta.isPaidRelay">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<span class="text-bold">Cost to join: </span>
|
<span class="text-bold">Cost to join: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div>
|
<div>
|
||||||
<span v-text="relay.config.costToJoin"></span>
|
<span v-text="relay.meta.costToJoin"></span>
|
||||||
<span class="text-bold q-ml-sm">sats</span>
|
<span class="text-bold q-ml-sm">sats</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div v-if="relay.config.costToJoin">
|
<div v-if="relay.meta.costToJoin">
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="createInvoice('join')"
|
@click="createInvoice('join')"
|
||||||
unelevated
|
unelevated
|
||||||
|
|
@ -89,37 +89,37 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="relay.config.isPaidRelay">
|
<q-card-section v-if="relay.meta.isPaidRelay">
|
||||||
<div class="row q-mt-md q-mb-md">
|
<div class="row q-mt-md q-mb-md">
|
||||||
<div class="col-2 q-pt-sm">
|
<div class="col-2 q-pt-sm">
|
||||||
<span class="text-bold">Storage cost: </span>
|
<span class="text-bold">Storage cost: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3 q-pt-sm">
|
<div class="col-3 q-pt-sm">
|
||||||
<span v-text="relay.config.storageCostValue"></span>
|
<span v-text="relay.meta.storageCostValue"></span>
|
||||||
<span class="text-bold q-ml-sm"> sats per</span>
|
<span class="text-bold q-ml-sm"> sats per</span>
|
||||||
<q-badge color="orange">
|
<q-badge color="orange">
|
||||||
<span v-text="relay.config.storageCostUnit"></span>
|
<span v-text="relay.meta.storageCostUnit"></span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<q-input
|
<q-input
|
||||||
v-if="relay.config.storageCostValue"
|
v-if="relay.meta.storageCostValue"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="unitsToBuy"
|
v-model="unitsToBuy"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
:label="relay.config.storageCostUnit"
|
:label="relay.meta.storageCostUnit"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 q-pt-sm">
|
<div class="col-2 q-pt-sm">
|
||||||
<div v-if="relay.config.storageCostValue">
|
<div v-if="relay.meta.storageCostValue">
|
||||||
<span class="text-bold q-ml-md" v-text="storageCost"></span>
|
<span class="text-bold q-ml-md" v-text="storageCost"></span>
|
||||||
<span>sats</span>
|
<span>sats</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<div v-if="relay.config.storageCostValue">
|
<div v-if="relay.meta.storageCostValue">
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="createInvoice('storage')"
|
@click="createInvoice('storage')"
|
||||||
unelevated
|
unelevated
|
||||||
|
|
@ -197,12 +197,10 @@
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
window.app = Vue.createApp({
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data() {
|
||||||
return {
|
return {
|
||||||
relay: JSON.parse('{{relay | tojson | safe}}'),
|
relay: JSON.parse('{{relay | tojson | safe}}'),
|
||||||
pubkey: '',
|
pubkey: '',
|
||||||
|
|
@ -213,14 +211,14 @@
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
storageCost: function () {
|
storageCost: function () {
|
||||||
if (!this.relay || !this.relay.config.storageCostValue) return 0
|
if (!this.relay || !this.relay.meta.storageCostValue) return 0
|
||||||
return this.unitsToBuy * this.relay.config.storageCostValue
|
return this.unitsToBuy * this.relay.meta.storageCostValue
|
||||||
},
|
},
|
||||||
wssLink: function () {
|
wssLink: function () {
|
||||||
this.relay.config.domain =
|
this.relay.meta.domain =
|
||||||
this.relay.config.domain || window.location.hostname
|
this.relay.meta.domain || window.location.hostname
|
||||||
return (
|
return (
|
||||||
'wss://' + this.relay.config.domain + '/nostrrelay/' + this.relay.id
|
'wss://' + this.relay.meta.domain + '/nostrrelay/' + this.relay.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -272,7 +270,7 @@
|
||||||
wsConnection.close()
|
wsConnection.close()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Failed to get invoice status',
|
message: 'Failed to get invoice status',
|
||||||
|
|
@ -280,8 +278,7 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
created: function () {}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.domain"
|
v-model.trim="relay.meta.domain"
|
||||||
type="text"
|
type="text"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.freeStorageValue"
|
v-model.trim="relay.meta.freeStorageValue"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Value"
|
hint="Value"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.freeStorageUnit"
|
v-model="relay.meta.freeStorageUnit"
|
||||||
type="text"
|
type="text"
|
||||||
hint="Unit"
|
hint="Unit"
|
||||||
:options="storageUnits"
|
:options="storageUnits"
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-2">
|
<div class="col-md-4 col-sm-2">
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="relay.config.freeStorageValue == 0"
|
v-if="relay.meta.freeStorageValue == 0"
|
||||||
color="orange"
|
color="orange"
|
||||||
class="float-right q-mb-md"
|
class="float-right q-mb-md"
|
||||||
><span>No free storage</span>
|
><span>No free storage</span>
|
||||||
|
|
@ -132,13 +132,13 @@
|
||||||
<div class="col-md-3 q-pr-lg">
|
<div class="col-md-3 q-pr-lg">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
color="secodary"
|
color="secodary"
|
||||||
v-model="relay.config.isPaidRelay"
|
v-model="relay.meta.isPaidRelay"
|
||||||
@input="togglePaidRelay"
|
@update:model-value="togglePaidRelay"
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="!relay.config.isPaidRelay && relay.config.freeStorageValue == 0"
|
v-if="!relay.meta.isPaidRelay && relay.meta.freeStorageValue == 0"
|
||||||
color="orange"
|
color="orange"
|
||||||
class="float-right q-mb-md"
|
class="float-right q-mb-md"
|
||||||
><span>No data will be stored. Read-only relay.</span>
|
><span>No data will be stored. Read-only relay.</span>
|
||||||
|
|
@ -146,7 +146,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="relay.config.isPaidRelay && relay.config.wallet">
|
<div v-if="relay.meta.isPaidRelay && relay.meta.wallet">
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
<div class="col-3 q-pr-lg">Wallet:</div>
|
<div class="col-3 q-pr-lg">Wallet:</div>
|
||||||
<div class="col-md-6 col-sm-8 q-pr-lg">
|
<div class="col-md-6 col-sm-8 q-pr-lg">
|
||||||
|
|
@ -154,7 +154,7 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
v-model="relay.config.wallet"
|
v-model="relay.meta.wallet"
|
||||||
:options="walletOptions"
|
:options="walletOptions"
|
||||||
label="Wallet *"
|
label="Wallet *"
|
||||||
>
|
>
|
||||||
|
|
@ -174,7 +174,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.costToJoin"
|
v-model.trim="relay.meta.costToJoin"
|
||||||
type="number"
|
type="number"
|
||||||
hint="sats"
|
hint="sats"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -189,7 +189,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-sm-4">
|
<div class="col-md-6 col-sm-4">
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="relay.config.costToJoin == 0"
|
v-if="relay.meta.costToJoin == 0"
|
||||||
color="green"
|
color="green"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
><span>Free to join</span>
|
><span>Free to join</span>
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.storageCostValue"
|
v-model.trim="relay.meta.storageCostValue"
|
||||||
type="number"
|
type="number"
|
||||||
hint="sats"
|
hint="sats"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -212,7 +212,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.storageCostUnit"
|
v-model="relay.meta.storageCostUnit"
|
||||||
type="text"
|
type="text"
|
||||||
hint="Unit"
|
hint="Unit"
|
||||||
:options="storageUnits"
|
:options="storageUnits"
|
||||||
|
|
@ -227,7 +227,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-0">
|
<div class="col-md-4 col-sm-0">
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="relay.config.storageCostValue == 0"
|
v-if="relay.meta.storageCostValue == 0"
|
||||||
color="green"
|
color="green"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
><span>Unlimited storage</span>
|
><span>Unlimited storage</span>
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.createdAtDaysPast"
|
v-model.trim="relay.meta.createdAtDaysPast"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
hint="Days"
|
hint="Days"
|
||||||
|
|
@ -255,7 +255,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.createdAtHoursPast"
|
v-model="relay.meta.createdAtHoursPast"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Hours"
|
hint="Hours"
|
||||||
:options="hours"
|
:options="hours"
|
||||||
|
|
@ -265,7 +265,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.createdAtMinutesPast"
|
v-model="relay.meta.createdAtMinutesPast"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Minutes"
|
hint="Minutes"
|
||||||
:options="range60"
|
:options="range60"
|
||||||
|
|
@ -275,7 +275,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.createdAtSecondsPast"
|
v-model="relay.meta.createdAtSecondsPast"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Seconds"
|
hint="Seconds"
|
||||||
:options="range60"
|
:options="range60"
|
||||||
|
|
@ -296,7 +296,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.createdAtDaysFuture"
|
v-model.trim="relay.meta.createdAtDaysFuture"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
hint="Days"
|
hint="Days"
|
||||||
|
|
@ -306,7 +306,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.createdAtHoursFuture"
|
v-model="relay.meta.createdAtHoursFuture"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Hours"
|
hint="Hours"
|
||||||
:options="hours"
|
:options="hours"
|
||||||
|
|
@ -316,7 +316,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.createdAtMinutesFuture"
|
v-model="relay.meta.createdAtMinutesFuture"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Minutes"
|
hint="Minutes"
|
||||||
:options="range60"
|
:options="range60"
|
||||||
|
|
@ -326,7 +326,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="relay.config.createdAtSecondsFuture"
|
v-model="relay.meta.createdAtSecondsFuture"
|
||||||
type="number"
|
type="number"
|
||||||
hint="Seconds"
|
hint="Seconds"
|
||||||
:options="range60"
|
:options="range60"
|
||||||
|
|
@ -348,7 +348,7 @@
|
||||||
<q-toggle
|
<q-toggle
|
||||||
color="secodary"
|
color="secodary"
|
||||||
class="q-ml-md q-mr-md"
|
class="q-ml-md q-mr-md"
|
||||||
v-model="relay.config.requireAuthFilter"
|
v-model="relay.meta.requireAuthFilter"
|
||||||
>For Filters</q-toggle
|
>For Filters</q-toggle
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -356,7 +356,7 @@
|
||||||
<q-toggle
|
<q-toggle
|
||||||
color="secodary"
|
color="secodary"
|
||||||
class="q-ml-md q-mr-md"
|
class="q-ml-md q-mr-md"
|
||||||
v-model="relay.config.requireAuthEvents"
|
v-model="relay.meta.requireAuthEvents"
|
||||||
>For All Events</q-toggle
|
>For All Events</q-toggle
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -370,7 +370,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="relay.config.requireAuthEvents"
|
v-if="relay.meta.requireAuthEvents"
|
||||||
class="row items-center no-wrap q-mb-md q-mt-md"
|
class="row items-center no-wrap q-mb-md q-mt-md"
|
||||||
>
|
>
|
||||||
<div class="col-3 q-pr-lg">Skip Auth For Events:</div>
|
<div class="col-3 q-pr-lg">Skip Auth For Events:</div>
|
||||||
|
|
@ -393,14 +393,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-7">
|
<div class="col-7">
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="e in relay.config.skipedAuthEvents"
|
v-for="e in relay.meta.skipedAuthEvents"
|
||||||
:key="e"
|
:key="e"
|
||||||
removable
|
removable
|
||||||
@remove="removeSkipAuthForEvent(e)"
|
@remove="removeSkipAuthForEvent(e)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
>
|
||||||
{{ e }}
|
<span v-text="e"></span>
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -425,14 +425,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-7">
|
<div class="col-7">
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="e in relay.config.forcedAuthEvents"
|
v-for="e in relay.meta.forcedAuthEvents"
|
||||||
:key="e"
|
:key="e"
|
||||||
removable
|
removable
|
||||||
@remove="removeForceAuthForEvent(e)"
|
@remove="removeForceAuthForEvent(e)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
>
|
||||||
{{ e }}
|
<span v-text="e"></span>
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -444,7 +444,7 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
v-model="relay.config.fullStorageAction"
|
v-model="relay.meta.fullStorageAction"
|
||||||
type="text"
|
type="text"
|
||||||
:options="fullStorageActions"
|
:options="fullStorageActions"
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|
@ -464,7 +464,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.limitPerFilter"
|
v-model.trim="relay.meta.limitPerFilter"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
|
@ -477,7 +477,7 @@
|
||||||
</q-tooltip></q-icon
|
</q-tooltip></q-icon
|
||||||
>
|
>
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="relay.config.limitPerFilter == 0"
|
v-if="relay.meta.limitPerFilter == 0"
|
||||||
color="green"
|
color="green"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
><span>No Limit</span>
|
><span>No Limit</span>
|
||||||
|
|
@ -490,7 +490,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.maxClientFilters"
|
v-model.trim="relay.meta.maxClientFilters"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
|
@ -504,7 +504,7 @@
|
||||||
</q-tooltip></q-icon
|
</q-tooltip></q-icon
|
||||||
>
|
>
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="relay.config.maxClientFilters == 0"
|
v-if="relay.meta.maxClientFilters == 0"
|
||||||
color="green"
|
color="green"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
><span>Unlimited Filters</span>
|
><span>Unlimited Filters</span>
|
||||||
|
|
@ -517,7 +517,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="relay.config.maxEventsPerHour"
|
v-model.trim="relay.meta.maxEventsPerHour"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
|
@ -530,7 +530,7 @@
|
||||||
</q-tooltip></q-icon
|
</q-tooltip></q-icon
|
||||||
>
|
>
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="relay.config.maxEventsPerHour == 0"
|
v-if="relay.meta.maxEventsPerHour == 0"
|
||||||
color="green"
|
color="green"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
><span>No Limit</span>
|
><span>No Limit</span>
|
||||||
|
|
@ -584,7 +584,7 @@
|
||||||
color="secodary"
|
color="secodary"
|
||||||
class="q-mr-lg"
|
class="q-mr-lg"
|
||||||
v-model="showAllowedAccounts"
|
v-model="showAllowedAccounts"
|
||||||
@input="getAccounts()"
|
@update:model-value="getAccounts()"
|
||||||
>Show Allowed Account</q-toggle
|
>Show Allowed Account</q-toggle
|
||||||
>
|
>
|
||||||
<q-toggle
|
<q-toggle
|
||||||
|
|
@ -592,7 +592,7 @@
|
||||||
color="secodary"
|
color="secodary"
|
||||||
class="q-mr-lg"
|
class="q-mr-lg"
|
||||||
v-model="showBlockedAccounts"
|
v-model="showBlockedAccounts"
|
||||||
@input="getAccounts()"
|
@update:model-value="getAccounts()"
|
||||||
>
|
>
|
||||||
Show Blocked Accounts</q-toggle
|
Show Blocked Accounts</q-toggle
|
||||||
>
|
>
|
||||||
|
|
@ -605,7 +605,7 @@
|
||||||
<q-table
|
<q-table
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
:data="accounts"
|
:rows="accounts"
|
||||||
row-key="pubkey"
|
row-key="pubkey"
|
||||||
:columns="accountsTable.columns"
|
:columns="accountsTable.columns"
|
||||||
:pagination.sync="accountsTable.pagination"
|
:pagination.sync="accountsTable.pagination"
|
||||||
|
|
@ -623,14 +623,14 @@
|
||||||
>
|
>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="pubkey" :props="props">
|
<q-td key="pubkey" :props="props">
|
||||||
{{props.row.pubkey}}
|
<span v-text="props.row.pubkey"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="allowed" :props="props">
|
<q-td key="allowed" :props="props">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secodary"
|
color="secodary"
|
||||||
v-model="props.row.allowed"
|
v-model="props.row.allowed"
|
||||||
@input="togglePublicKey(props.row, 'allow')"
|
@update:model-value="togglePublicKey(props.row, 'allow')"
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="blocked" :props="props">
|
<q-td key="blocked" :props="props">
|
||||||
|
|
@ -638,12 +638,17 @@
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secodary"
|
color="secodary"
|
||||||
v-model="props.row.blocked"
|
v-model="props.row.blocked"
|
||||||
@input="togglePublicKey(props.row, 'block')"
|
@update:model-value="togglePublicKey(props.row, 'block')"
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td auto-width> {{props.row.paid_to_join}} </q-td>
|
|
||||||
<q-td auto-width> {{props.row.sats}} </q-td>
|
<q-td auto-width
|
||||||
<q-td auto-width> {{props.row.storage}} </q-td>
|
><span v-text="props.row.paid_to_join"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width> <span v-text="props.row.sats"></span></q-td>
|
||||||
|
<q-td auto-width
|
||||||
|
><span v-text="props.row.storage"></span>
|
||||||
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
48
tests/conftest.py
Normal file
48
tests/conftest.py
Normal 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"]]
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"name": "kind 0, metadata",
|
"name": "kind 0, metadata",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
|
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"created_at": 1675242172,
|
"created_at": 1675242172,
|
||||||
"kind": 0,
|
"kind": 0,
|
||||||
|
|
@ -19,6 +21,8 @@
|
||||||
"content": "i126",
|
"content": "i126",
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"created_at": 1675239988,
|
"created_at": 1675239988,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
||||||
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
|
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
|
||||||
|
|
@ -42,6 +46,8 @@
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"created_at": 1675240147,
|
"created_at": 1675240147,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
|
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
|
||||||
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
|
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
|
||||||
|
|
@ -51,6 +57,8 @@
|
||||||
"name": "kind 3, contact list",
|
"name": "kind 3, contact list",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
|
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
|
||||||
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
|
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
|
||||||
"created_at": 1675095502,
|
"created_at": 1675095502,
|
||||||
"kind": 3,
|
"kind": 3,
|
||||||
|
|
@ -68,6 +76,8 @@
|
||||||
"name": "kind 3, relays",
|
"name": "kind 3, relays",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
|
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"created_at": 1675175242,
|
"created_at": 1675175242,
|
||||||
"kind": 3,
|
"kind": 3,
|
||||||
|
|
@ -105,6 +115,8 @@
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"created_at": 1675240247,
|
"created_at": 1675240247,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
|
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
|
||||||
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
|
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
|
||||||
|
|
@ -122,6 +134,8 @@
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"created_at": 1675241034,
|
"created_at": 1675241034,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
|
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
|
||||||
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
|
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
|
||||||
|
|
@ -145,6 +159,8 @@
|
||||||
],
|
],
|
||||||
"content": "#[0]",
|
"content": "#[0]",
|
||||||
"created_at": 1675240471,
|
"created_at": 1675240471,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
|
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
|
||||||
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
|
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
|
||||||
|
|
@ -166,6 +182,8 @@
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"created_at": 1675240377,
|
"created_at": 1675240377,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
|
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
|
||||||
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
|
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
|
||||||
|
|
@ -178,6 +196,8 @@
|
||||||
"tags": [["d", "chats/null/lastOpened"]],
|
"tags": [["d", "chats/null/lastOpened"]],
|
||||||
"content": "1675242945",
|
"content": "1675242945",
|
||||||
"created_at": 1675242945,
|
"created_at": 1675242945,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
|
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
|
||||||
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
|
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
|
||||||
|
|
@ -190,6 +210,8 @@
|
||||||
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
|
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
|
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"created_at": 1675242172,
|
"created_at": 1675242172,
|
||||||
"kind": 0,
|
"kind": 0,
|
||||||
|
|
@ -206,6 +228,8 @@
|
||||||
"content": "i126",
|
"content": "i126",
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"created_at": 1675239988,
|
"created_at": 1675239988,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
||||||
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
FIXTURES_PATH = "tests/extensions/nostrrelay/fixture"
|
FIXTURES_PATH = "./tests/fixture"
|
||||||
|
|
||||||
|
|
||||||
def get_fixtures(file):
|
def get_fixtures(file):
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.extensions.nostrrelay.relay.client_connection import ( # type: ignore
|
from ..relay.client_connection import (
|
||||||
NostrClientConnection,
|
NostrClientConnection,
|
||||||
)
|
)
|
||||||
from lnbits.extensions.nostrrelay.relay.client_manager import ( # type: ignore
|
from ..relay.client_manager import (
|
||||||
NostrClientManager,
|
NostrClientManager,
|
||||||
)
|
)
|
||||||
from lnbits.extensions.nostrrelay.relay.relay import RelaySpec # type: ignore
|
from ..relay.relay import RelaySpec
|
||||||
|
|
||||||
from .helpers import get_fixtures
|
from .helpers import get_fixtures
|
||||||
|
|
||||||
fixtures = get_fixtures("clients")
|
fixtures = get_fixtures("clients")
|
||||||
|
|
@ -26,10 +24,10 @@ RELAY_ID = "relay_01"
|
||||||
class MockWebSocket(WebSocket):
|
class MockWebSocket(WebSocket):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sent_messages = []
|
self.sent_messages = []
|
||||||
self.fake_wire: asyncio.Queue[str] = asyncio.Queue(0)
|
self.fake_wire = asyncio.Queue(0)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def accept(self):
|
async def accept(self, *_, **__):
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
async def receive_text(self) -> str:
|
async def receive_text(self) -> str:
|
||||||
|
|
@ -42,8 +40,8 @@ class MockWebSocket(WebSocket):
|
||||||
async def wire_mock_data(self, data: dict):
|
async def wire_mock_data(self, data: dict):
|
||||||
await self.fake_wire.put(dumps(data))
|
await self.fake_wire.put(dumps(data))
|
||||||
|
|
||||||
async def close(self, code: int = 1000, reason: Optional[str] = None) -> None:
|
async def close(self, code: int = 1000, reason: str | None = None) -> None:
|
||||||
logger.info(reason)
|
logger.info(f"{code}: {reason}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -71,6 +69,9 @@ async def test_alice_and_bob():
|
||||||
await alice_deletes_post01__bob_is_notified(ws_alice, ws_bob)
|
await alice_deletes_post01__bob_is_notified(ws_alice, ws_bob)
|
||||||
|
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
|
||||||
async def init_clients():
|
async def init_clients():
|
||||||
client_manager = NostrClientManager()
|
client_manager = NostrClientManager()
|
||||||
await client_manager.enable_relay(RELAY_ID, RelaySpec())
|
await client_manager.enable_relay(RELAY_ID, RelaySpec())
|
||||||
|
|
@ -78,12 +79,15 @@ async def init_clients():
|
||||||
ws_alice = MockWebSocket()
|
ws_alice = MockWebSocket()
|
||||||
client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice)
|
client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice)
|
||||||
await client_manager.add_client(client_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()
|
ws_bob = MockWebSocket()
|
||||||
client_bob = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_bob)
|
client_bob = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_bob)
|
||||||
await client_manager.add_client(client_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
|
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(
|
assert ws_alice.sent_messages[1] == dumps(
|
||||||
alice["post01_response_ok"]
|
alice["post01_response_ok"]
|
||||||
), "Alice: Wrong confirmation for post01"
|
), "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(
|
assert ws_alice.sent_messages[3] == dumps(
|
||||||
alice["meta_update_response"]
|
alice["meta_update_response"]
|
||||||
), "Alice: Expected confirmation for meta update"
|
), "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 ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"])
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
print("### ws_alice.sent_message", ws_alice.sent_messages)
|
|
||||||
print("### ws_bob.sent_message", ws_bob.sent_messages)
|
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
len(ws_bob.sent_messages) == 2
|
len(ws_bob.sent_messages) == 2
|
||||||
), "Bob: Expected 1 confirmation for create contact list"
|
), "Bob: Expected 1 confirmation for create contact list"
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,21 @@
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from lnbits.extensions.nostrrelay.crud import ( # type: ignore
|
from ..crud import (
|
||||||
create_event,
|
create_event,
|
||||||
get_event,
|
get_event,
|
||||||
get_events,
|
get_events,
|
||||||
)
|
)
|
||||||
from lnbits.extensions.nostrrelay.relay.event import NostrEvent # type: ignore
|
from ..relay.event import NostrEvent
|
||||||
from lnbits.extensions.nostrrelay.relay.filter import NostrFilter # type: ignore
|
from ..relay.filter import NostrFilter
|
||||||
|
from .conftest import EventFixture
|
||||||
from .helpers import get_fixtures
|
|
||||||
|
|
||||||
RELAY_ID = "r1"
|
RELAY_ID = "r1"
|
||||||
|
|
||||||
|
|
||||||
class EventFixture(BaseModel):
|
def test_valid_event_id_and_signature(valid_events: list[EventFixture]):
|
||||||
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]):
|
|
||||||
for f in valid_events:
|
for f in valid_events:
|
||||||
try:
|
try:
|
||||||
f.data.check_signature()
|
f.data.check_signature()
|
||||||
|
|
@ -45,14 +24,14 @@ def test_valid_event_id_and_signature(valid_events: List[EventFixture]):
|
||||||
raise e
|
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:
|
for f in invalid_events:
|
||||||
with pytest.raises(ValueError, match=f.exception):
|
with pytest.raises(ValueError, match=f.exception):
|
||||||
f.data.check_signature()
|
f.data.check_signature()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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"
|
author = "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
||||||
event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
|
event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
|
||||||
reply_event_id = "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894"
|
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
|
# insert all events in DB before doing an query
|
||||||
for e in all_events:
|
for e in all_events:
|
||||||
await create_event(RELAY_ID, e, None)
|
await create_event(e)
|
||||||
|
|
||||||
for f in valid_events:
|
for f in valid_events:
|
||||||
await get_by_id(f.data, f.name)
|
await get_by_id(f.data, f.name)
|
||||||
|
|
@ -85,90 +64,90 @@ async def get_by_id(data: NostrEvent, test_name: str):
|
||||||
), f"Restored event is different for fixture '{test_name}'"
|
), f"Restored event is different for fixture '{test_name}'"
|
||||||
|
|
||||||
|
|
||||||
async def filter_by_id(all_events: List[NostrEvent], 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 len(events) == 1, f"Expected one queried event '{test_name}'"
|
||||||
assert events[0].json() != json.dumps(
|
assert events[0].json() != json.dumps(
|
||||||
data.json()
|
data.json()
|
||||||
), f"Queried event is different for fixture '{test_name}'"
|
), 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 len(filtered_events) == 1, f"Expected one filter event '{test_name}'"
|
||||||
assert filtered_events[0].json() != json.dumps(
|
assert filtered_events[0].json() != json.dumps(
|
||||||
data.json()
|
data.json()
|
||||||
), f"Filtered event is different for fixture '{test_name}'"
|
), f"Filtered event is different for fixture '{test_name}'"
|
||||||
|
|
||||||
|
|
||||||
async def filter_by_author(all_events: List[NostrEvent], author):
|
async def filter_by_author(all_events: list[NostrEvent], author):
|
||||||
filter = NostrFilter(authors=[author])
|
nostr_filter = NostrFilter(authors=[author])
|
||||||
events_by_author = await get_events(RELAY_ID, filter)
|
events_by_author = await get_events(RELAY_ID, nostr_filter)
|
||||||
assert len(events_by_author) == 5, f"Failed to query by authors"
|
assert len(events_by_author) == 5, "Failed to query by authors"
|
||||||
|
|
||||||
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) == 5, f"Failed to filter by authors"
|
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)
|
# todo: check why constructor does not work for fields with aliases (#e, #p)
|
||||||
filter = NostrFilter()
|
nostr_filter = NostrFilter()
|
||||||
filter.p.append(author)
|
nostr_filter.p.append(author)
|
||||||
|
|
||||||
events_related_to_author = await get_events(RELAY_ID, filter)
|
events_related_to_author = await get_events(RELAY_ID, nostr_filter)
|
||||||
assert len(events_related_to_author) == 5, f"Failed to query by tag 'p'"
|
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)]
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
assert len(filtered_events) == 5, f"Failed to filter by tag 'p'"
|
assert len(filtered_events) == 5, "Failed to filter by tag 'p'"
|
||||||
|
|
||||||
|
|
||||||
async def filter_by_tag_e(all_events: List[NostrEvent], event_id):
|
async def filter_by_tag_e(all_events: list[NostrEvent], event_id):
|
||||||
filter = NostrFilter()
|
nostr_filter = NostrFilter()
|
||||||
filter.e.append(event_id)
|
nostr_filter.e.append(event_id)
|
||||||
|
|
||||||
events_related_to_event = await get_events(RELAY_ID, filter)
|
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
|
||||||
assert len(events_related_to_event) == 2, f"Failed to query by tag 'e'"
|
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)]
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
assert len(filtered_events) == 2, f"Failed to filter by tag 'e'"
|
assert len(filtered_events) == 2, "Failed to filter by tag 'e'"
|
||||||
|
|
||||||
|
|
||||||
async def filter_by_tag_e_and_p(
|
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()
|
nostr_filter = NostrFilter()
|
||||||
filter.p.append(author)
|
nostr_filter.p.append(author)
|
||||||
filter.e.append(event_id)
|
nostr_filter.e.append(event_id)
|
||||||
|
|
||||||
events_related_to_event = await get_events(RELAY_ID, filter)
|
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
|
||||||
assert len(events_related_to_event) == 1, f"Failed to quert by tags 'e' & 'p'"
|
assert len(events_related_to_event) == 1, "Failed to quert by tags 'e' & 'p'"
|
||||||
assert (
|
assert (
|
||||||
events_related_to_event[0].id == reply_event_id
|
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)]
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
assert len(filtered_events) == 1, f"Failed to filter by tags 'e' & 'p'"
|
assert len(filtered_events) == 1, "Failed to filter by tags 'e' & 'p'"
|
||||||
assert (
|
assert (
|
||||||
filtered_events[0].id == reply_event_id
|
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(
|
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])
|
nostr_filter = NostrFilter(authors=[author])
|
||||||
filter.p.append(author)
|
nostr_filter.p.append(author)
|
||||||
filter.e.append(event_id)
|
nostr_filter.e.append(event_id)
|
||||||
events_related_to_event = await get_events(RELAY_ID, filter)
|
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
|
||||||
assert (
|
assert (
|
||||||
len(events_related_to_event) == 1
|
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 (
|
assert (
|
||||||
events_related_to_event[0].id == reply_event_id
|
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)]
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
assert len(filtered_events) == 1, f"Failed to filter by 'author' and tags 'e' & 'p'"
|
assert len(filtered_events) == 1, "Failed to filter by 'author' and tags 'e' & 'p'"
|
||||||
assert (
|
assert (
|
||||||
filtered_events[0].id == reply_event_id
|
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
17
tests/test_init.py
Normal 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
9
toc.md
|
|
@ -1,22 +1,29 @@
|
||||||
# Terms and Conditions for LNbits Extension
|
# Terms and Conditions for LNbits Extension
|
||||||
|
|
||||||
## 1. Acceptance of Terms
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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].
|
||||||
|
|
|
||||||
21
views.py
21
views.py
|
|
@ -1,28 +1,29 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
from . import nostrrelay_ext, nostrrelay_renderer
|
|
||||||
from .crud import get_public_relay
|
from .crud import get_public_relay
|
||||||
from .helpers import relay_info_response
|
from .helpers import relay_info_response
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
nostrrelay_generic_router: APIRouter = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_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)):
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return nostrrelay_renderer().TemplateResponse(
|
return nostrrelay_renderer().TemplateResponse(
|
||||||
"nostrrelay/index.html", {"request": request, "user": user.dict()}
|
"nostrrelay/index.html", {"request": request, "user": user.json()}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_ext.get("/{relay_id}")
|
@nostrrelay_generic_router.get("/{relay_id}")
|
||||||
async def nostrrelay(request: Request, relay_id: str):
|
async def nostrrelay(request: Request, relay_id: str):
|
||||||
relay_public_data = await get_public_relay(relay_id)
|
relay_public_data = await get_public_relay(relay_id)
|
||||||
|
|
||||||
|
|
|
||||||
372
views_api.py
372
views_api.py
|
|
@ -1,23 +1,18 @@
|
||||||
from http import HTTPStatus
|
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.crud import get_user
|
||||||
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
|
||||||
check_admin,
|
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
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 (
|
from .crud import (
|
||||||
create_account,
|
create_account,
|
||||||
create_relay,
|
create_relay,
|
||||||
|
|
@ -34,13 +29,14 @@ from .crud import (
|
||||||
)
|
)
|
||||||
from .helpers import extract_domain, normalize_public_key, relay_info_response
|
from .helpers import extract_domain, normalize_public_key, relay_info_response
|
||||||
from .models import BuyOrder, NostrAccount, NostrPartialAccount
|
from .models import BuyOrder, NostrAccount, NostrPartialAccount
|
||||||
from .relay.client_manager import NostrClientConnection, NostrClientManager
|
from .relay.client_manager import NostrClientConnection
|
||||||
from .relay.relay import NostrRelay
|
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):
|
async def websocket_endpoint(relay_id: str, websocket: WebSocket):
|
||||||
client = NostrClientConnection(relay_id=relay_id, websocket=websocket)
|
client = NostrClientConnection(relay_id=relay_id, websocket=websocket)
|
||||||
client_accepted = await client_manager.add_client(client)
|
client_accepted = await client_manager.add_client(client)
|
||||||
|
|
@ -54,33 +50,26 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket):
|
||||||
client_manager.remove_client(client)
|
client_manager.remove_client(client)
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_ext.post("/api/v1/relay")
|
@nostrrelay_api_router.post("/api/v1/relay")
|
||||||
async def api_create_relay(
|
async def api_create_relay(
|
||||||
data: NostrRelay,
|
data: NostrRelay,
|
||||||
request: Request,
|
request: Request,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> NostrRelay:
|
) -> NostrRelay:
|
||||||
|
data.user_id = key_info.wallet.user
|
||||||
if len(data.id):
|
if len(data.id):
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(data.user_id)
|
||||||
assert user, "User not found."
|
assert user, "User not found."
|
||||||
assert user.admin, "Only admin users can set the relay ID"
|
assert user.admin, "Only admin users can set the relay ID"
|
||||||
else:
|
else:
|
||||||
data.id = urlsafe_short_hash()[:8]
|
data.id = urlsafe_short_hash()[:8]
|
||||||
|
|
||||||
try:
|
data.meta.domain = extract_domain(str(request.url))
|
||||||
data.config.domain = extract_domain(str(request.url))
|
relay = await create_relay(data)
|
||||||
relay = await create_relay(wallet.wallet.user, data)
|
return relay
|
||||||
return relay
|
|
||||||
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot create relay",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_ext.patch("/api/v1/relay/{relay_id}")
|
@nostrrelay_api_router.patch("/api/v1/relay/{relay_id}")
|
||||||
async def api_update_relay(
|
async def api_update_relay(
|
||||||
relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)
|
relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> NostrRelay:
|
) -> NostrRelay:
|
||||||
|
|
@ -90,98 +79,66 @@ async def api_update_relay(
|
||||||
detail="Cannot change the relay id",
|
detail="Cannot change the relay id",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
relay = await get_relay(wallet.wallet.user, data.id)
|
||||||
relay = await get_relay(wallet.wallet.user, data.id)
|
if not relay:
|
||||||
if not relay:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Relay not found",
|
|
||||||
)
|
|
||||||
updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)})
|
|
||||||
updated_relay = await update_relay(wallet.wallet.user, updated_relay)
|
|
||||||
# activate & deactivate have their own endpoint
|
|
||||||
updated_relay.active = relay.active
|
|
||||||
|
|
||||||
if updated_relay.active:
|
|
||||||
await client_manager.enable_relay(relay_id, updated_relay.config)
|
|
||||||
else:
|
|
||||||
await client_manager.disable_relay(relay_id)
|
|
||||||
|
|
||||||
return updated_relay
|
|
||||||
|
|
||||||
except HTTPException as ex:
|
|
||||||
raise ex
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail="Cannot update relay",
|
detail="Relay not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
async def api_toggle_relay(
|
||||||
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> NostrRelay:
|
) -> NostrRelay:
|
||||||
|
relay = await get_relay(wallet.wallet.user, relay_id)
|
||||||
try:
|
if not relay:
|
||||||
relay = await get_relay(wallet.wallet.user, relay_id)
|
|
||||||
if not relay:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Relay not found",
|
|
||||||
)
|
|
||||||
relay.active = not relay.active
|
|
||||||
updated_relay = await update_relay(wallet.wallet.user, relay)
|
|
||||||
|
|
||||||
if relay.active:
|
|
||||||
await client_manager.enable_relay(relay_id, relay.config)
|
|
||||||
else:
|
|
||||||
await client_manager.disable_relay(relay_id)
|
|
||||||
|
|
||||||
return updated_relay
|
|
||||||
|
|
||||||
except HTTPException as ex:
|
|
||||||
raise ex
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail="Cannot update relay",
|
detail="Relay not found",
|
||||||
)
|
)
|
||||||
|
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(
|
async def api_get_relays(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> List[NostrRelay]:
|
) -> list[NostrRelay]:
|
||||||
try:
|
return await get_relays(wallet.wallet.user)
|
||||||
return await get_relays(wallet.wallet.user)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot fetch relays",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_ext.get("/api/v1/relay-info")
|
@nostrrelay_api_router.get("/api/v1/relay-info")
|
||||||
async def api_get_relay_info() -> JSONResponse:
|
async def api_get_relay_info() -> JSONResponse:
|
||||||
return relay_info_response(NostrRelay.info())
|
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(
|
async def api_get_relay(
|
||||||
relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
) -> Optional[NostrRelay]:
|
) -> NostrRelay | None:
|
||||||
try:
|
relay = await get_relay(wallet.wallet.user, relay_id)
|
||||||
relay = await get_relay(wallet.wallet.user, relay_id)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot fetch relay",
|
|
||||||
)
|
|
||||||
if not relay:
|
if not relay:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
|
@ -190,106 +147,65 @@ async def api_get_relay(
|
||||||
return 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(
|
async def api_create_or_update_account(
|
||||||
data: NostrPartialAccount,
|
data: NostrPartialAccount,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> NostrAccount:
|
) -> NostrAccount:
|
||||||
|
data.pubkey = normalize_public_key(data.pubkey)
|
||||||
try:
|
account = await get_account(data.relay_id, data.pubkey)
|
||||||
data.pubkey = normalize_public_key(data.pubkey)
|
if not account:
|
||||||
|
account = NostrAccount(
|
||||||
account = await get_account(data.relay_id, data.pubkey)
|
pubkey=data.pubkey,
|
||||||
if not account:
|
relay_id=data.relay_id,
|
||||||
account = NostrAccount(
|
blocked=data.blocked or False,
|
||||||
pubkey=data.pubkey,
|
allowed=data.allowed or False,
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
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(
|
async def api_delete_account(
|
||||||
relay_id: str,
|
relay_id: str,
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
):
|
):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pubkey = normalize_public_key(pubkey)
|
pubkey = normalize_public_key(pubkey)
|
||||||
|
|
||||||
return await delete_account(relay_id, pubkey)
|
|
||||||
|
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=f"Invalid pubkey: {ex!s}",
|
||||||
)
|
) from ex
|
||||||
except HTTPException as ex:
|
return await delete_account(relay_id, pubkey)
|
||||||
raise ex
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot create account",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_ext.get("/api/v1/account")
|
@nostrrelay_api_router.get("/api/v1/account")
|
||||||
async def api_get_accounts(
|
async def api_get_accounts(
|
||||||
relay_id: str,
|
relay_id: str,
|
||||||
allowed: bool = False,
|
allowed: bool = False,
|
||||||
blocked: bool = True,
|
blocked: bool = True,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> List[NostrAccount]:
|
) -> list[NostrAccount]:
|
||||||
try:
|
# make sure the user has access to the relay
|
||||||
# make sure the user has access to the relay
|
relay = await get_relay(wallet.wallet.user, relay_id)
|
||||||
relay = await get_relay(wallet.wallet.user, relay_id)
|
if not relay:
|
||||||
if not relay:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Relay not found",
|
|
||||||
)
|
|
||||||
accounts = await get_accounts(relay.id, allowed, blocked)
|
|
||||||
return accounts
|
|
||||||
except ValueError as ex:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=str(ex),
|
detail="Relay not found",
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
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(
|
async def api_delete_relay(
|
||||||
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
|
|
@ -302,77 +218,55 @@ async def api_delete_relay(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot delete relay",
|
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):
|
async def api_pay_to_join(data: BuyOrder):
|
||||||
try:
|
pubkey = normalize_public_key(data.pubkey)
|
||||||
pubkey = normalize_public_key(data.pubkey)
|
relay = await get_relay_by_id(data.relay_id)
|
||||||
relay = await get_relay_by_id(data.relay_id)
|
if not relay:
|
||||||
if not relay:
|
raise HTTPException(
|
||||||
raise HTTPException(
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
detail="Relay 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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return {"invoice": payment_request}
|
amount = 0
|
||||||
except ValueError as ex:
|
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(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=f"Unknown action: '{data.action}'",
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
payment = await create_invoice(
|
||||||
@nostrrelay_ext.delete("/api/v1", status_code=HTTPStatus.OK)
|
wallet_id=relay.meta.wallet,
|
||||||
async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
|
amount=amount,
|
||||||
for t in scheduled_tasks:
|
memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}",
|
||||||
try:
|
extra={
|
||||||
t.cancel()
|
"tag": "nostrrely",
|
||||||
except Exception as ex:
|
"action": data.action,
|
||||||
logger.warning(ex)
|
"relay_id": relay.id,
|
||||||
|
"pubkey": pubkey,
|
||||||
try:
|
"storage_to_buy": storage_to_buy,
|
||||||
await client_manager.stop()
|
},
|
||||||
except Exception as ex:
|
)
|
||||||
logger.warning(ex)
|
return {"invoice": payment.bolt11}
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue