Compare commits
171 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4811fcf352 | |||
| b74af2628e | |||
| 8d6f482de0 | |||
|
|
5e95b309fe | ||
|
|
8547864254 | ||
|
|
dcc3204735 | ||
|
|
8bfd792548 | ||
|
|
c4efb87b70 |
||
|
|
a53d2d7767 |
||
|
|
35584a230f |
||
|
|
15079c3e58 |
||
|
|
22df5868de |
||
|
|
687d7b89c1 |
||
|
|
5a1a400f45 |
||
|
|
3dc066fbd4 |
||
|
|
73054fd5ce |
||
|
|
2bdbbb274d |
||
|
|
cc6752003a |
||
|
|
28121184c3 |
||
|
|
94d383baff |
||
|
|
6d4f561e4e | ||
|
|
d448ba6fa5 | ||
|
|
349bfa49d1 |
||
|
|
43eed0df35 |
||
|
|
94ae34158c |
||
|
|
473614f8be |
||
|
|
0215986a59 |
||
|
|
3c45acc4d5 | ||
|
|
07a07fc079 | ||
|
|
ccf620d476 | ||
|
|
dc50d3493d | ||
|
|
8ed361230b | ||
|
|
fddc8a1d22 | ||
|
|
1a1875e177 | ||
|
|
f19fb4a18e | ||
|
|
f7fb926c52 | ||
|
|
28c0947afb | ||
|
|
527afa0c8c | ||
|
|
3aa4875558 | ||
|
|
30ffcf7f55 | ||
|
|
218b324347 | ||
|
|
6e1b5dd0bb | ||
|
|
67dda89c81 | ||
|
|
63be2b5b2d | ||
|
|
5e313c0282 | ||
|
|
16b9d93dca | ||
|
|
8df0dc2f52 | ||
|
|
dd9dbbe818 | ||
|
|
f4d4706237 | ||
|
|
8c860f851a | ||
|
|
abe8c65c4c | ||
|
|
ffb0177003 | ||
|
|
d66184c077 | ||
|
|
8d316c4887 | ||
|
|
729f36e993 | ||
|
|
2d4e836676 |
||
|
|
7ec3045130 | ||
|
|
230729483c | ||
|
|
855812cb8f | ||
|
|
2ebc83a286 | ||
|
|
6be0169ea9 | ||
|
|
c42d81f696 | ||
|
|
aa68d2a79a | ||
|
|
c46c903703 | ||
|
|
818072fe29 | ||
|
|
d5d8b5e1b5 | ||
|
|
30ab2b8f70 | ||
|
|
8c24109dd3 |
||
|
|
eaf0979254 |
||
|
|
012d861d16 |
||
|
|
bae7d284b3 |
||
|
|
d38b3c73ff |
||
|
|
de25a7a12d |
||
|
|
fd18ebe015 |
||
|
|
1dc6ddf9c5 |
||
|
|
afde9ae37c |
||
|
|
5c0209b6c0 | ||
|
|
a1d7c474b0 | ||
|
|
b5f7aa0c78 |
||
|
|
ba191cabbc | ||
|
|
2c0bcce8c7 | ||
|
|
94730ba464 | ||
|
|
7d98bc1deb | ||
|
|
a120394304 |
||
|
|
6130de3987 |
||
|
|
1a300d4528 |
||
|
|
1099fe7e87 |
||
|
|
09707151c5 | ||
|
|
6e67443ea4 | ||
|
|
aed098499a | ||
|
|
5a984bddcd | ||
|
|
2c5dfbbf92 | ||
|
|
d8ca9f830a | ||
|
|
bc1af610db | ||
|
|
d0f38346e3 | ||
|
|
2099a8b7bb | ||
|
|
88d53bd73d | ||
|
|
9aa0fdbd2c | ||
|
|
a8a2ef5e27 | ||
|
|
58723a387f | ||
|
|
366dae2082 | ||
|
|
b472e97454 | ||
|
|
1c81ca300f | ||
|
|
3648dc212c | ||
|
|
d0c6f1392b | ||
|
|
be606934bf | ||
|
|
44ae8086cc | ||
|
|
dfda2367a2 | ||
|
|
2233521a43 | ||
|
|
d0d3f1f6ed | ||
|
|
23547f5187 | ||
|
|
4970334713 | ||
|
|
bd5957b443 | ||
|
|
330fceaf34 | ||
|
|
b7840ced08 | ||
|
|
ebada934b0 | ||
|
|
8678090e7b | ||
|
|
db3ad2e32f | ||
|
|
ae68f210cd | ||
|
|
d5aff47717 | ||
|
|
0c72f868ed | ||
|
|
9ccd94aae3 | ||
|
|
1eda457067 | ||
|
|
55f9142f3d | ||
|
|
339f2b70c1 | ||
|
|
a2f3951efa | ||
|
|
d01084881a | ||
|
|
9fef72ae0c | ||
|
|
f109833a36 | ||
|
|
48c21a76d9 | ||
|
|
c488f5d5e0 | ||
|
|
f331a19d75 | ||
|
|
cb81726297 | ||
|
|
50fd1942cc | ||
|
|
f5c873ec4d | ||
|
|
868e02d3c2 | ||
|
|
bddab70677 | ||
|
|
b4094ad2f5 | ||
|
|
2cb9d083c6 | ||
|
|
4b96f65c85 | ||
|
|
24f803921e | ||
|
|
9501286c1f | ||
|
|
423364a5c6 | ||
|
|
7215d37fe9 | ||
|
|
5339dde64a | ||
|
|
43dc3e85ce | ||
|
|
e1fe19b115 | ||
|
|
30dfb03d2d | ||
|
|
9c5b7f69dd | ||
|
|
534cf04210 | ||
|
|
b098dc5d77 | ||
|
|
6527d04977 | ||
|
|
d0341911b9 | ||
|
|
f97cd1dff6 | ||
|
|
4e5c2657c9 | ||
|
|
b2b5058784 | ||
|
|
14df3a6ffb | ||
|
|
25dde9571c | ||
|
|
f689e829eb | ||
|
|
709e201fec | ||
|
|
1e2307a758 | ||
|
|
43f3fbfb44 | ||
|
|
a74cf42feb | ||
|
|
c05ecb054d | ||
|
|
cab870f6ff | ||
|
|
3fe4984026 | ||
|
|
1bc8640640 | ||
|
|
999bb1683f | ||
|
|
f0857a5609 | ||
|
|
4c9d1a4395 | ||
|
|
e4197ce66d |
53 changed files with 6839 additions and 1565 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
MOCK.md export-ignore
|
||||||
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'
|
||||||
58
.github/workflows/release.yml
vendored
Normal file
58
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Create github release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes
|
||||||
|
|
||||||
|
pullrequest:
|
||||||
|
needs: [release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repository: lnbits/lnbits-extensions
|
||||||
|
path: './lnbits-extensions'
|
||||||
|
|
||||||
|
- name: setup git user
|
||||||
|
run: |
|
||||||
|
git config --global user.name "alan"
|
||||||
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
|
||||||
|
- name: Create pull request in extensions repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repo_name: '${{ github.event.repository.name }}'
|
||||||
|
tag: '${{ github.ref_name }}'
|
||||||
|
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
||||||
|
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
|
||||||
|
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
|
||||||
|
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
||||||
|
run: |
|
||||||
|
cd lnbits-extensions
|
||||||
|
git checkout -b $branch
|
||||||
|
|
||||||
|
# if there is another open PR
|
||||||
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
|
||||||
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -am "$title"
|
||||||
|
git push origin $branch
|
||||||
|
|
||||||
|
# check if pr exists before creating it
|
||||||
|
gh config set pager cat
|
||||||
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions
|
||||||
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"
|
||||||
131
README.md
131
README.md
|
|
@ -1,12 +1,131 @@
|
||||||
# Nostr Relay
|
# Nostr Relay
|
||||||
|
|
||||||
## One click and spin up your own Nostr relay. Share with the world, or use privately.
|
### One click and spin up your own Nostr relay. Share with the world, or use privately.
|
||||||
|
|
||||||
A simple UI wrapper for the great python relay library <a href="https://code.pobblelabs.org/fossil/nostr_relay/">nostr_relay</a>.
|
**Configure**:
|
||||||
|
|
||||||
UI for diagnostics and management (key alow/ban lists, rate limiting) coming soon!
|
- Free Plan: with limitted storage (limit can be changed)
|
||||||
|
- Paid Plan: `pay to join` and `pay for storage`
|
||||||
|
- Storage Limit (can buy more)
|
||||||
|
- Rate Limit
|
||||||
|
- Filter Limit
|
||||||
|
- Allow/Block accounts
|
||||||
|
- Optional Auth for `Events` and `Filters`
|
||||||
|
|
||||||
### Usage
|
## Supported NIPs
|
||||||
|
|
||||||
1. Enable extension
|
- [x] **NIP-01**: Basic protocol flow
|
||||||
2. Enable relay
|
- [x] Regular Events
|
||||||
|
- [x] Replaceable Events (kinds 10000-19999)
|
||||||
|
- [x] Ephemeral Events (kinds 20000-29999)
|
||||||
|
- [x] Addressable Events (kinds 30000-39999)
|
||||||
|
- [x] **NIP-02**: Contact List and Petnames
|
||||||
|
- `kind: 3`: delete past contact lists as soon as the relay receives a new one
|
||||||
|
- [x] **NIP-04**: Encrypted Direct Message
|
||||||
|
- if `AUTH` enabled: send only to the intended target
|
||||||
|
- [x] **NIP-09**: Event Deletion
|
||||||
|
- [x] 'e' tags: Delete regular events by event ID
|
||||||
|
- [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier)
|
||||||
|
- [x] **NIP-11**: Relay Information Document
|
||||||
|
- > **Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/)
|
||||||
|
- [ ] **NIP-12**: Generic Tag Queries
|
||||||
|
- todo
|
||||||
|
- [x] **NIP-15**: End of Stored Events Notice
|
||||||
|
- [x] **NIP-16**: Event Treatment
|
||||||
|
- [x] Regular Events
|
||||||
|
- [x] Replaceable Events
|
||||||
|
- [x] Ephemeral Events
|
||||||
|
- [x] **NIP-17**: Private Direct Messages
|
||||||
|
- `kind: 1059` gift wraps stored and broadcast like regular events
|
||||||
|
- if `AUTH` enabled for `kind: 1059`: deliver only to the recipient
|
||||||
|
named in the `p` tag (same gating as NIP-04)
|
||||||
|
- encryption (NIP-44) and wrapping (NIP-59) are client-side concerns;
|
||||||
|
the relay handles transport only
|
||||||
|
- [x] **NIP-20**: Command Results
|
||||||
|
- todo: use correct prefixes
|
||||||
|
- [x] **NIP-22**: Event created_at Limits
|
||||||
|
- [ ] **NIP-26**: Delegated Event Signing
|
||||||
|
- not planned
|
||||||
|
- [x] **NIP-28** Public Chat
|
||||||
|
- `kind: 41`: handled similar to `kind 0` metadata events
|
||||||
|
- [x] **NIP-33**: Addressable Events (moved to NIP-01)
|
||||||
|
- ✅ Implemented as part of NIP-01 addressable events
|
||||||
|
- [ ] **NIP-40**: Expiration Timestamp
|
||||||
|
- todo
|
||||||
|
- [x] **NIP-42**: Authentication of clients to relays
|
||||||
|
- todo: use correct prefix
|
||||||
|
- [x] **NIP-44**: Encrypted Payloads (Versioned)
|
||||||
|
- relay treats payloads as opaque; encryption is client-side
|
||||||
|
- [ ] **NIP-50**: Search Capability
|
||||||
|
- todo
|
||||||
|
- [x] **NIP-59**: Gift Wrap
|
||||||
|
- `kind: 13` (seal) and `kind: 1059` (gift wrap) accepted; unwrapping is client-side
|
||||||
|
|
||||||
|
## Create Relay
|
||||||
|
|
||||||
|
Creating a new relay is straightforward. Just click `New Relay` then enter the Relay Info.
|
||||||
|
|
||||||
|
> **Note**: admin users can select a relay id. Regular users will be assigned a generated relay id.
|
||||||
|
> The relay can be activated/deactivated.
|
||||||
|
|
||||||
|
- **New Relay Dialog**
|
||||||
|
- 
|
||||||
|
|
||||||
|
## Configure Relay
|
||||||
|
|
||||||
|
Find your Relay in the list and click the expand button (`+`) to configure it.
|
||||||
|
|
||||||
|
### Relay Info
|
||||||
|
|
||||||
|
This tab contains data according to `NIP-11` (Relay Information Document).
|
||||||
|
|
||||||
|
> **Note**: the `domain` is added automatically and shoud be corrected manually if needed. This value is used for `NIP-42` (Authentication of clients to relays)
|
||||||
|
|
||||||
|
- **Relay Info Tab**
|
||||||
|
- 
|
||||||
|
|
||||||
|
### Payment
|
||||||
|
|
||||||
|
By default the relay is free to access, but it can be configured to ask for payments.
|
||||||
|
It is encourage to also activate the `Require Auth` option for paid relays.
|
||||||
|
|
||||||
|
> **Note**: check the info button (`I`) tooltip for a description of each field.
|
||||||
|
|
||||||
|
- **Payment Config Tab**
|
||||||
|
- 
|
||||||
|
|
||||||
|
Click on the Relay ID (or visit `https://{your_domain}/nostrrelay/${relay_id}`) for the Relay public page.
|
||||||
|
Here the entry and storage fees can be paid.
|
||||||
|
|
||||||
|
- **Relay Public Page**
|
||||||
|
- 
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
Configure `NIP-22` (_Event `created_at` Limits_), `NIP-42` (_Authentication of clients to relays_) and other Relay parameters.
|
||||||
|
|
||||||
|
Some configurations are not standard (`NIPs`) but they help control what clients are allowed to do, thus blocking (some) attack vectors.
|
||||||
|
|
||||||
|
> **Note**: check the info button (`I`) tooltip for a description of each field.
|
||||||
|
|
||||||
|
- **Config Tab**
|
||||||
|
- 
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
|
||||||
|
Allows the Relay operator to `Block` or `Allow` certain accounts.
|
||||||
|
|
||||||
|
If an account is `allowed` then it is not required to `pay to join`.
|
||||||
|
|
||||||
|
When an account is `blocked` it does not matter if it `paid to join` or if it is `allowed`.
|
||||||
|
|
||||||
|
- **Accounts Tab**
|
||||||
|
- 
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Create Symbolic Link:
|
||||||
|
|
||||||
|
```
|
||||||
|
ln -s /Users/my-user/git-repos/nostr-relay-extension/ /Users/my-user/git-repos/lnbits/lnbits/extensions/nostrrelay
|
||||||
|
```
|
||||||
|
|
|
||||||
54
__init__.py
54
__init__.py
|
|
@ -1,25 +1,59 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 .tasks import wait_for_paid_invoices
|
||||||
db = Database("ext_nostrrelay")
|
from .views import nostrrelay_generic_router
|
||||||
|
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 = [
|
||||||
{
|
{
|
||||||
"path": "/nostrrelay/static",
|
"path": "/nostrrelay/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/nostrrelay/static"),
|
|
||||||
"name": "nostrrelay_static",
|
"name": "nostrrelay_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
nostrrelay_redirect_paths = [
|
||||||
|
{
|
||||||
|
"from_path": "/",
|
||||||
|
"redirect_to_path": "/api/v1/relay-info",
|
||||||
|
"header_filters": {"accept": "application/nostr+json"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
def nostrrelay_renderer():
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
return template_renderer(["lnbits/extensions/nostrrelay/templates"])
|
|
||||||
|
|
||||||
|
|
||||||
from .views import * # noqa
|
async def nostrrelay_stop():
|
||||||
from .views_api import * # noqa
|
for task in scheduled_tasks:
|
||||||
|
try:
|
||||||
|
task.cancel()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
try:
|
||||||
|
await client_manager.stop()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
|
def nostrrelay_start():
|
||||||
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
|
|
||||||
|
task = create_permanent_unique_task("ext_nostrrelay", wait_for_paid_invoices)
|
||||||
|
scheduled_tasks.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"db",
|
||||||
|
"nostrrelay_ext",
|
||||||
|
"nostrrelay_start",
|
||||||
|
"nostrrelay_stop",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,165 +1,3 @@
|
||||||
import json
|
from .relay.client_manager import NostrClientManager
|
||||||
from typing import Any, Callable, List, Optional
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
client_manager: NostrClientManager = NostrClientManager()
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .crud import (
|
|
||||||
create_event,
|
|
||||||
delete_events,
|
|
||||||
get_all_active_relays_ids,
|
|
||||||
get_event,
|
|
||||||
get_events,
|
|
||||||
mark_events_deleted,
|
|
||||||
)
|
|
||||||
from .models import NostrEvent, NostrEventType, NostrFilter
|
|
||||||
|
|
||||||
|
|
||||||
class NostrClientManager:
|
|
||||||
def __init__(self: "NostrClientManager"):
|
|
||||||
self.clients: List["NostrClientConnection"] = []
|
|
||||||
self.active_relays: Optional[List[str]] = None
|
|
||||||
|
|
||||||
async def add_client(self, client: "NostrClientConnection") -> bool:
|
|
||||||
allow_connect = await self.allow_client_to_connect(client.relay_id, client.websocket)
|
|
||||||
if not allow_connect:
|
|
||||||
return False
|
|
||||||
setattr(client, "broadcast_event", self.broadcast_event)
|
|
||||||
self.clients.append(client)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def remove_client(self, client: "NostrClientConnection"):
|
|
||||||
self.clients.remove(client)
|
|
||||||
|
|
||||||
async def broadcast_event(self, source: "NostrClientConnection", event: NostrEvent):
|
|
||||||
for client in self.clients:
|
|
||||||
if client != source:
|
|
||||||
await client.notify_event(event)
|
|
||||||
|
|
||||||
async def allow_client_to_connect(self, relay_id:str, websocket: WebSocket) -> bool:
|
|
||||||
if not self.active_relays:
|
|
||||||
self.active_relays = await get_all_active_relays_ids()
|
|
||||||
|
|
||||||
if relay_id not in self.active_relays:
|
|
||||||
await websocket.close(reason=f"Relay '{relay_id}' is not active")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def toggle_relay(self, relay_id: str, active: bool):
|
|
||||||
if not self.active_relays:
|
|
||||||
self.active_relays = await get_all_active_relays_ids()
|
|
||||||
if active:
|
|
||||||
self.active_relays.append(relay_id)
|
|
||||||
else:
|
|
||||||
self.active_relays = [r for r in self.active_relays if r != relay_id]
|
|
||||||
await self.stop_clients_for_relay(relay_id)
|
|
||||||
|
|
||||||
async def stop_clients_for_relay(self, relay_id: str):
|
|
||||||
for client in self.clients:
|
|
||||||
if client.relay_id == relay_id:
|
|
||||||
await client.stop(reason=f"Relay '{relay_id}' has been deactivated.")
|
|
||||||
|
|
||||||
|
|
||||||
class NostrClientConnection:
|
|
||||||
broadcast_event: Callable
|
|
||||||
|
|
||||||
def __init__(self, relay_id: str, websocket: WebSocket):
|
|
||||||
self.websocket = websocket
|
|
||||||
self.relay_id = relay_id
|
|
||||||
self.filters: List[NostrFilter] = []
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
await self.websocket.accept()
|
|
||||||
while True:
|
|
||||||
json_data = await self.websocket.receive_text()
|
|
||||||
print("### received: ", json_data)
|
|
||||||
try:
|
|
||||||
data = json.loads(json_data)
|
|
||||||
|
|
||||||
resp = await self.__handle_message(data)
|
|
||||||
for r in resp:
|
|
||||||
await self.websocket.send_text(json.dumps(r))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
|
|
||||||
async def stop(self, reason: Optional[str]):
|
|
||||||
try:
|
|
||||||
message = reason if reason else "Server closed webocket"
|
|
||||||
await self.websocket.send_text(json.dumps(["NOTICE", message]))
|
|
||||||
await self.websocket.close()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def notify_event(self, event: NostrEvent) -> bool:
|
|
||||||
for filter in self.filters:
|
|
||||||
if filter.matches(event):
|
|
||||||
resp = event.serialize_response(filter.subscription_id)
|
|
||||||
await self.websocket.send_text(json.dumps(resp))
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def __handle_message(self, data: List) -> List:
|
|
||||||
if len(data) < 2:
|
|
||||||
return []
|
|
||||||
|
|
||||||
message_type = data[0]
|
|
||||||
if message_type == NostrEventType.EVENT:
|
|
||||||
await self.__handle_event(NostrEvent.parse_obj(data[1]))
|
|
||||||
return []
|
|
||||||
if message_type == NostrEventType.REQ:
|
|
||||||
if len(data) != 3:
|
|
||||||
return []
|
|
||||||
return await self.__handle_request(data[1], NostrFilter.parse_obj(data[2]))
|
|
||||||
if message_type == NostrEventType.CLOSE:
|
|
||||||
self.__handle_close(data[1])
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def __handle_event(self, e: NostrEvent):
|
|
||||||
resp_nip20: List[Any] = ["OK", e.id]
|
|
||||||
try:
|
|
||||||
e.check_signature()
|
|
||||||
if e.is_replaceable_event():
|
|
||||||
await delete_events(
|
|
||||||
self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey])
|
|
||||||
)
|
|
||||||
await create_event(self.relay_id, e)
|
|
||||||
await self.broadcast_event(self, e)
|
|
||||||
if e.is_delete_event():
|
|
||||||
await self.__handle_delete_event(e)
|
|
||||||
resp_nip20 += [True, ""]
|
|
||||||
except ValueError:
|
|
||||||
resp_nip20 += [False, "invalid: wrong event `id` or `sig`"]
|
|
||||||
except Exception:
|
|
||||||
event = await get_event(self.relay_id, e.id)
|
|
||||||
# todo: handle NIP20 in detail
|
|
||||||
resp_nip20 += [event != None, f"error: failed to create event"]
|
|
||||||
|
|
||||||
await self.websocket.send_text(json.dumps(resp_nip20))
|
|
||||||
|
|
||||||
async def __handle_delete_event(self, event: NostrEvent):
|
|
||||||
# NIP 09
|
|
||||||
filter = NostrFilter(authors=[event.pubkey])
|
|
||||||
filter.ids = [t[1] for t in event.tags if t[0] == "e"]
|
|
||||||
events_to_delete = await get_events(self.relay_id, filter, False)
|
|
||||||
ids = [e.id for e in events_to_delete if not e.is_delete_event()]
|
|
||||||
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids))
|
|
||||||
|
|
||||||
async def __handle_request(self, subscription_id: str, filter: NostrFilter) -> List:
|
|
||||||
filter.subscription_id = subscription_id
|
|
||||||
self.remove_filter(subscription_id)
|
|
||||||
self.filters.append(filter)
|
|
||||||
events = await get_events(self.relay_id, filter)
|
|
||||||
serialized_events = [
|
|
||||||
event.serialize_response(subscription_id) for event in events
|
|
||||||
]
|
|
||||||
resp_nip15 = ["EOSE", subscription_id]
|
|
||||||
serialized_events.append(resp_nip15)
|
|
||||||
return serialized_events
|
|
||||||
|
|
||||||
def __handle_close(self, subscription_id: str):
|
|
||||||
self.remove_filter(subscription_id)
|
|
||||||
|
|
||||||
def remove_filter(self, subscription_id: str):
|
|
||||||
self.filters = [f for f in self.filters if f.subscription_id != subscription_id]
|
|
||||||
|
|
|
||||||
40
config.json
40
config.json
|
|
@ -1,6 +1,42 @@
|
||||||
{
|
{
|
||||||
"name": "Nostr Relay",
|
"name": "Nostr Relay",
|
||||||
|
"version": "1.1.0",
|
||||||
"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",
|
||||||
"contributors": ["arcbtc", "DCs"]
|
"min_lnbits_version": "1.4.0",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "motorina0",
|
||||||
|
"uri": "https://github.com/motorina0",
|
||||||
|
"role": "Contributor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dni",
|
||||||
|
"uri": "https://github.com/dni",
|
||||||
|
"role": "Contributor"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/static/image/1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/static/image/2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/static/image/3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/static/image/4.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/static/image/5.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/static/image/6.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description_md": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/description.md",
|
||||||
|
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nostrrelay/main/toc.md",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
441
crud.py
441
crud.py
|
|
@ -1,228 +1,313 @@
|
||||||
import json
|
import json
|
||||||
from typing import Any, List, Optional
|
|
||||||
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.db import Database
|
||||||
|
|
||||||
from . import db
|
from .models import NostrAccount, NostrEventTags
|
||||||
from .models import NostrEvent, NostrFilter, NostrRelay
|
from .relay.event import NostrEvent
|
||||||
|
from .relay.filter import NostrFilter
|
||||||
|
from .relay.relay import NostrRelay, RelayPublicSpec
|
||||||
|
|
||||||
########################## RELAYS ####################
|
db = Database("ext_nostrrelay")
|
||||||
|
|
||||||
async def create_relay(user_id: str, r: NostrRelay) -> NostrRelay:
|
|
||||||
await db.execute(
|
async def create_relay(relay: NostrRelay) -> NostrRelay:
|
||||||
"""
|
await db.insert("nostrrelay.relays", relay)
|
||||||
INSERT INTO nostrrelay.relays (user_id, id, name, description, pubkey, contact)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(user_id, r.id, r.name, r.description, r.pubkey, r.contact,),
|
|
||||||
)
|
|
||||||
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:
|
|
||||||
await db.execute(
|
async def update_relay(relay: NostrRelay) -> NostrRelay:
|
||||||
"""
|
await db.update("nostrrelay.relays", relay, "WHERE user_id = :user_id AND id = :id")
|
||||||
UPDATE nostrrelay.relays
|
return relay
|
||||||
SET (name, description, pubkey, contact, active) = (?, ?, ?, ?, ?)
|
|
||||||
WHERE user_id = ? AND id = ?
|
|
||||||
""",
|
async def get_relay(user_id: str, relay_id: str) -> NostrRelay | None:
|
||||||
(r.name, r.description, r.pubkey, r.contact, r.active, user_id, r.id),
|
return await db.fetchone(
|
||||||
|
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
|
||||||
|
{"user_id": user_id, "id": relay_id},
|
||||||
|
NostrRelay,
|
||||||
)
|
)
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]:
|
|
||||||
row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,))
|
|
||||||
|
|
||||||
return NostrRelay.from_row(row) if row else None
|
async def get_relay_by_id(relay_id: str) -> NostrRelay | None:
|
||||||
|
"""Note: it does not require `user_id`. Can read any relay. Use it with care."""
|
||||||
|
return await db.fetchone(
|
||||||
|
"SELECT * FROM nostrrelay.relays WHERE id = :id",
|
||||||
|
{"id": relay_id},
|
||||||
|
NostrRelay,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_relays(user_id: str) -> List[NostrRelay]:
|
|
||||||
rows = await db.fetchall("""SELECT * FROM nostrrelay.relays WHERE user_id = ? ORDER BY id ASC""", (user_id,))
|
|
||||||
|
|
||||||
return [NostrRelay.from_row(row) for row in rows]
|
async def get_relays(user_id: str) -> list[NostrRelay]:
|
||||||
|
return await db.fetchall(
|
||||||
|
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id ORDER BY id ASC",
|
||||||
|
{"user_id": user_id},
|
||||||
|
NostrRelay,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_all_active_relays_ids() -> List[str]:
|
|
||||||
rows = await db.fetchall("SELECT id FROM nostrrelay.relays WHERE active = true",)
|
|
||||||
return [r["id"] for r in rows]
|
|
||||||
|
|
||||||
async def get_public_relay(relay_id: str) -> Optional[dict]:
|
async def get_config_for_all_active_relays() -> dict:
|
||||||
row = await db.fetchone("""SELECT * FROM nostrrelay.relays WHERE id = ?""", (relay_id,))
|
relays = await db.fetchall(
|
||||||
|
"SELECT * FROM nostrrelay.relays WHERE active = true",
|
||||||
|
model=NostrRelay,
|
||||||
|
)
|
||||||
|
active_relay_configs = {}
|
||||||
|
for relay in relays:
|
||||||
|
active_relay_configs[relay.id] = relay.meta
|
||||||
|
|
||||||
if not row:
|
return active_relay_configs
|
||||||
|
|
||||||
|
|
||||||
|
async def get_public_relay(relay_id: str) -> dict | None:
|
||||||
|
relay = await db.fetchone(
|
||||||
|
"SELECT * FROM nostrrelay.relays WHERE id = :id",
|
||||||
|
{"id": relay_id},
|
||||||
|
NostrRelay,
|
||||||
|
)
|
||||||
|
if not relay:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
relay = NostrRelay.from_row(row)
|
|
||||||
return {
|
return {
|
||||||
|
**NostrRelay.info(),
|
||||||
"id": relay.id,
|
"id": relay.id,
|
||||||
"name": relay.name,
|
"name": relay.name,
|
||||||
"description":relay.description,
|
"description": relay.description,
|
||||||
"pubkey":relay.pubkey,
|
"pubkey": relay.pubkey,
|
||||||
"contact":relay.contact,
|
"contact": relay.contact,
|
||||||
"supported_nips":relay.supported_nips,
|
"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("""DELETE FROM nostrrelay.relays WHERE user_id = ? AND id = ?""", (user_id, relay_id,))
|
|
||||||
|
|
||||||
|
|
||||||
########################## EVENTS ####################
|
|
||||||
async def create_event(relay_id: str, e: NostrEvent):
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"DELETE FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
|
||||||
INSERT INTO nostrrelay.events (
|
{"user_id": user_id, "id": relay_id},
|
||||||
relay_id,
|
|
||||||
id,
|
|
||||||
pubkey,
|
|
||||||
created_at,
|
|
||||||
kind,
|
|
||||||
content,
|
|
||||||
sig
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(relay_id, e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_event(event: NostrEvent):
|
||||||
|
event_ = await get_event(event.relay_id, event.id)
|
||||||
|
if event_:
|
||||||
|
return None
|
||||||
|
await db.insert("nostrrelay.events", event)
|
||||||
|
|
||||||
# todo: optimize with bulk insert
|
# 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,
|
||||||
async def get_events(relay_id: str, filter: NostrFilter, include_tags = True) -> List[NostrEvent]:
|
event_id=event.id,
|
||||||
values, query = build_select_events_query(relay_id, filter)
|
name=name,
|
||||||
|
value=value,
|
||||||
rows = await db.fetchall(query, tuple(values))
|
extra=extra,
|
||||||
|
|
||||||
events = []
|
|
||||||
for row in rows:
|
|
||||||
event = NostrEvent.from_row(row)
|
|
||||||
if include_tags:
|
|
||||||
event.tags = await get_event_tags(relay_id, event.id)
|
|
||||||
events.append(event)
|
|
||||||
|
|
||||||
return events
|
|
||||||
|
|
||||||
async def get_event(relay_id: str, id: str) -> Optional[NostrEvent]:
|
|
||||||
row = await db.fetchone("SELECT * FROM nostrrelay.events WHERE relay_id = ? AND id = ?", (relay_id, id,))
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
event = NostrEvent.from_row(row)
|
|
||||||
event.tags = await get_event_tags(relay_id, id)
|
|
||||||
return event
|
|
||||||
|
|
||||||
async def mark_events_deleted(relay_id: str, filter: NostrFilter):
|
|
||||||
if filter.is_empty():
|
|
||||||
return None
|
|
||||||
_, where, values = build_where_clause(relay_id, filter)
|
|
||||||
|
|
||||||
await db.execute(f"""UPDATE nostrrelay.events SET deleted=true WHERE {" AND ".join(where)}""", tuple(values))
|
|
||||||
|
|
||||||
async def delete_events(relay_id: str, filter: NostrFilter):
|
|
||||||
if filter.is_empty():
|
|
||||||
return None
|
|
||||||
_, where, values = build_where_clause(relay_id, filter)
|
|
||||||
|
|
||||||
query = f"""DELETE from nostrrelay.events WHERE {" AND ".join(where)}"""
|
|
||||||
await db.execute(query, tuple(values))
|
|
||||||
|
|
||||||
|
|
||||||
async def create_event_tags(
|
|
||||||
relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str]
|
|
||||||
):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO nostrrelay.event_tags (
|
|
||||||
relay_id,
|
|
||||||
event_id,
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
extra
|
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
await create_event_tags(_tag)
|
||||||
""",
|
|
||||||
(relay_id, event_id, tag_name, tag_value, extra_values),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_event_tags(
|
async def get_events(
|
||||||
relay_id: str, event_id: str
|
relay_id: str, nostr_filter: NostrFilter, include_tags=True
|
||||||
) -> List[List[str]]:
|
) -> list[NostrEvent]:
|
||||||
rows = await db.fetchall(
|
|
||||||
"SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?",
|
|
||||||
(relay_id, event_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
tags: List[List[str]] = []
|
|
||||||
for row in rows:
|
|
||||||
tag = [row["name"], row["value"]]
|
|
||||||
extra = row["extra"]
|
|
||||||
if extra:
|
|
||||||
tag += json.loads(extra)
|
|
||||||
tags.append(tag)
|
|
||||||
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
def build_select_events_query(relay_id:str, filter:NostrFilter):
|
|
||||||
inner_joins, where, values = build_where_clause(relay_id, filter)
|
|
||||||
|
|
||||||
|
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT id, pubkey, created_at, kind, content, sig
|
SELECT * FROM nostrrelay.events
|
||||||
FROM nostrrelay.events
|
{" ".join(inner_joins)}
|
||||||
{" ".join(inner_joins)}
|
|
||||||
WHERE { " AND ".join(where)}
|
WHERE { " AND ".join(where)}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# todo: check & enforce range
|
# todo: check & enforce range
|
||||||
if filter.limit and filter.limit > 0:
|
if nostr_filter.limit and nostr_filter.limit > 0:
|
||||||
query += f" LIMIT {filter.limit}"
|
query += f" LIMIT {nostr_filter.limit}"
|
||||||
|
|
||||||
return values, query
|
events = await db.fetchall(query, values, NostrEvent)
|
||||||
|
|
||||||
def build_where_clause(relay_id:str, filter:NostrFilter):
|
for event in events:
|
||||||
inner_joins = []
|
if include_tags:
|
||||||
where = ["deleted=false", "nostrrelay.events.relay_id = ?"]
|
event.tags = await get_event_tags(relay_id, event.id)
|
||||||
values: List[Any] = [relay_id]
|
|
||||||
|
|
||||||
if len(filter.e):
|
return events
|
||||||
values += filter.e
|
|
||||||
e_s = ",".join(["?"] * len(filter.e))
|
|
||||||
inner_joins.append("INNER JOIN nostrrelay.event_tags e_tags ON nostrrelay.events.id = e_tags.event_id")
|
|
||||||
where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')")
|
|
||||||
|
|
||||||
if len(filter.p):
|
|
||||||
values += filter.p
|
|
||||||
p_s = ",".join(["?"] * len(filter.p))
|
|
||||||
inner_joins.append("INNER JOIN nostrrelay.event_tags p_tags ON nostrrelay.events.id = p_tags.event_id")
|
|
||||||
where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'")
|
|
||||||
|
|
||||||
if len(filter.ids) != 0:
|
async def get_event(relay_id: str, event_id: str) -> NostrEvent | None:
|
||||||
ids = ",".join(["?"] * len(filter.ids))
|
event = await db.fetchone(
|
||||||
where.append(f"id IN ({ids})")
|
"SELECT * FROM nostrrelay.events WHERE relay_id = :relay_id AND id = :id",
|
||||||
values += filter.ids
|
{"relay_id": relay_id, "id": event_id},
|
||||||
|
NostrEvent,
|
||||||
|
)
|
||||||
|
if not event:
|
||||||
|
return None
|
||||||
|
event.tags = await get_event_tags(relay_id, event_id)
|
||||||
|
return event
|
||||||
|
|
||||||
if len(filter.authors) != 0:
|
|
||||||
authors = ",".join(["?"] * len(filter.authors))
|
|
||||||
where.append(f"pubkey IN ({authors})")
|
|
||||||
values += filter.authors
|
|
||||||
|
|
||||||
if len(filter.kinds) != 0:
|
async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> int:
|
||||||
kinds = ",".join(["?"] * len(filter.kinds))
|
"""
|
||||||
where.append(f"kind IN ({kinds})")
|
Returns the storage space in bytes for all the events of a public key.
|
||||||
values += filter.kinds
|
Deleted events are also counted
|
||||||
|
"""
|
||||||
|
row: dict = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT SUM(size) as sum FROM nostrrelay.events
|
||||||
|
WHERE relay_id = :relay_id AND publisher = :publisher GROUP BY publisher
|
||||||
|
""",
|
||||||
|
{"relay_id": relay_id, "publisher": publisher_pubkey},
|
||||||
|
)
|
||||||
|
|
||||||
if filter.since:
|
if not row:
|
||||||
where.append("reated_at >= ?")
|
return 0
|
||||||
values += [filter.since]
|
|
||||||
|
|
||||||
if filter.until:
|
return round(row["sum"])
|
||||||
where.append("created_at <= ?")
|
|
||||||
values += [filter.until]
|
|
||||||
|
|
||||||
|
|
||||||
return inner_joins, where, values
|
|
||||||
|
async def get_prunable_events(relay_id: str, pubkey: str) -> list[tuple[str, int]]:
|
||||||
|
"""
|
||||||
|
Return the oldest 10 000 events. Only the `id` and the size are returned,
|
||||||
|
so the data size should be small
|
||||||
|
"""
|
||||||
|
events = await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM nostrrelay.events
|
||||||
|
WHERE relay_id = :relay_id AND pubkey = :pubkey
|
||||||
|
ORDER BY created_at ASC LIMIT 10000
|
||||||
|
""",
|
||||||
|
{"relay_id": relay_id, "pubkey": pubkey},
|
||||||
|
NostrEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [(event.id, event.size_bytes) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter):
|
||||||
|
if nostr_filter.is_empty():
|
||||||
|
return None
|
||||||
|
_, where, values = nostr_filter.to_sql_components(relay_id)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE nostrrelay.events SET deleted=true WHERE {' AND '.join(where)}",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_events(relay_id: str, nostr_filter: NostrFilter):
|
||||||
|
if nostr_filter.is_empty():
|
||||||
|
return None
|
||||||
|
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
|
||||||
|
|
||||||
|
if inner_joins:
|
||||||
|
# Use subquery for DELETE operations with JOINs
|
||||||
|
subquery = f"""
|
||||||
|
SELECT nostrrelay.events.id FROM nostrrelay.events
|
||||||
|
{" ".join(inner_joins)}
|
||||||
|
WHERE {" AND ".join(where)}
|
||||||
|
"""
|
||||||
|
query = f"DELETE FROM nostrrelay.events WHERE id IN ({subquery})"
|
||||||
|
else:
|
||||||
|
# Simple DELETE without JOINs
|
||||||
|
query = f"DELETE FROM nostrrelay.events WHERE {' AND '.join(where)}"
|
||||||
|
|
||||||
|
await db.execute(query, values)
|
||||||
|
# todo: delete tags
|
||||||
|
|
||||||
|
|
||||||
|
# move to services
|
||||||
|
async def prune_old_events(relay_id: str, pubkey: str, space_to_regain: int):
|
||||||
|
prunable_events = await get_prunable_events(relay_id, pubkey)
|
||||||
|
prunable_event_ids = []
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
for pe in prunable_events:
|
||||||
|
prunable_event_ids.append(pe[0])
|
||||||
|
size += pe[1]
|
||||||
|
|
||||||
|
if size > space_to_regain:
|
||||||
|
break
|
||||||
|
|
||||||
|
await delete_events(relay_id, NostrFilter(ids=prunable_event_ids))
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_all_events(relay_id: str):
|
||||||
|
await db.execute(
|
||||||
|
"DELETE from nostrrelay.events WHERE relay_id = :id",
|
||||||
|
{"id": relay_id},
|
||||||
|
)
|
||||||
|
# todo: delete tags
|
||||||
|
|
||||||
|
|
||||||
|
async def create_event_tags(tag: NostrEventTags):
|
||||||
|
await db.insert("nostrrelay.event_tags", tag)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_tags(relay_id: str, event_id: str) -> list[list[str]]:
|
||||||
|
_tags = await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM nostrrelay.event_tags
|
||||||
|
WHERE relay_id = :relay_id and event_id = :event_id
|
||||||
|
""",
|
||||||
|
{"relay_id": relay_id, "event_id": event_id},
|
||||||
|
model=NostrEventTags,
|
||||||
|
)
|
||||||
|
|
||||||
|
tags: list[list[str]] = []
|
||||||
|
for tag in _tags:
|
||||||
|
_tag = [tag.name, tag.value]
|
||||||
|
if tag.extra:
|
||||||
|
_tag += json.loads(tag.extra)
|
||||||
|
tags.append(_tag)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
async def create_account(account: NostrAccount) -> NostrAccount:
|
||||||
|
await db.insert("nostrrelay.accounts", account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def update_account(account: NostrAccount) -> NostrAccount:
|
||||||
|
await db.update(
|
||||||
|
"nostrrelay.accounts",
|
||||||
|
account,
|
||||||
|
"WHERE relay_id = :relay_id AND pubkey = :pubkey",
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_account(relay_id: str, pubkey: str):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM nostrrelay.accounts
|
||||||
|
WHERE relay_id = :id AND pubkey = :pubkey
|
||||||
|
""",
|
||||||
|
{"id": relay_id, "pubkey": pubkey},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account(
|
||||||
|
relay_id: str,
|
||||||
|
pubkey: str,
|
||||||
|
) -> NostrAccount | None:
|
||||||
|
return await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT * FROM nostrrelay.accounts
|
||||||
|
WHERE relay_id = :id AND pubkey = :pubkey
|
||||||
|
""",
|
||||||
|
{"id": relay_id, "pubkey": pubkey},
|
||||||
|
NostrAccount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_accounts(
|
||||||
|
relay_id: str,
|
||||||
|
allowed=True,
|
||||||
|
blocked=False,
|
||||||
|
) -> list[NostrAccount]:
|
||||||
|
if not allowed and not blocked:
|
||||||
|
return []
|
||||||
|
return await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM nostrrelay.accounts
|
||||||
|
WHERE relay_id = :id AND allowed = :allowed OR blocked = :blocked
|
||||||
|
""",
|
||||||
|
{"id": relay_id, "allowed": allowed, "blocked": blocked},
|
||||||
|
NostrAccount,
|
||||||
|
)
|
||||||
|
|
|
||||||
8
description.md
Normal file
8
description.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
Create a Nostr relay in just 2 steps!
|
||||||
|
|
||||||
|
Optional settings include:
|
||||||
|
|
||||||
|
- Charging for storage
|
||||||
|
- Charging for joining
|
||||||
|
- Npub allow/ban list (for restricting access)
|
||||||
|
- Pruning and filtering
|
||||||
37
helpers.py
Normal file
37
helpers.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from bech32 import bech32_decode, convertbits
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_public_key(pubkey: str) -> str:
|
||||||
|
if pubkey.startswith("npub1"):
|
||||||
|
_, decoded_data = bech32_decode(pubkey)
|
||||||
|
if not decoded_data:
|
||||||
|
raise ValueError("Public Key is not valid npub")
|
||||||
|
|
||||||
|
decoded_data_bits = convertbits(decoded_data, 5, 8, False)
|
||||||
|
if not decoded_data_bits:
|
||||||
|
raise ValueError("Public Key is not valid npub")
|
||||||
|
return bytes(decoded_data_bits).hex()
|
||||||
|
|
||||||
|
# check if valid hex
|
||||||
|
if len(pubkey) != 64:
|
||||||
|
raise ValueError("Public Key is not valid hex")
|
||||||
|
int(pubkey, 16)
|
||||||
|
return pubkey
|
||||||
|
|
||||||
|
|
||||||
|
def extract_domain(url: str) -> str:
|
||||||
|
return urlparse(url).netloc
|
||||||
|
|
||||||
|
|
||||||
|
def relay_info_response(relay_public_data: dict) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
content=relay_public_data,
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
"id": "nostrrelay",
|
"id": "nostrrelay",
|
||||||
"organisation": "lnbits",
|
"organisation": "lnbits",
|
||||||
"repository": "nostr-relay-extension"
|
"repository": "nostrrelay"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,15 @@ async def m001_initial(db):
|
||||||
CREATE TABLE nostrrelay.events (
|
CREATE TABLE nostrrelay.events (
|
||||||
relay_id TEXT NOT NULL,
|
relay_id TEXT NOT NULL,
|
||||||
deleted BOOLEAN DEFAULT false,
|
deleted BOOLEAN DEFAULT false,
|
||||||
id TEXT PRIMARY KEY,
|
publisher TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
pubkey TEXT NOT NULL,
|
pubkey TEXT NOT NULL,
|
||||||
created_at {db.big_int} NOT NULL,
|
created_at {db.big_int} NOT NULL,
|
||||||
kind INT NOT NULL,
|
kind INT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
sig TEXT NOT NULL
|
sig TEXT NOT NULL,
|
||||||
|
size {db.big_int} DEFAULT 0,
|
||||||
|
PRIMARY KEY (relay_id, id)
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -44,3 +47,18 @@ async def m001_initial(db):
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE nostrrelay.accounts (
|
||||||
|
relay_id TEXT NOT NULL,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
sats {db.big_int} DEFAULT 0,
|
||||||
|
storage {db.big_int} DEFAULT 0,
|
||||||
|
paid_to_join BOOLEAN DEFAULT false,
|
||||||
|
allowed BOOLEAN DEFAULT false,
|
||||||
|
blocked BOOLEAN DEFAULT false,
|
||||||
|
PRIMARY KEY (relay_id, pubkey)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
|
||||||
169
models.py
169
models.py
|
|
@ -1,146 +1,45 @@
|
||||||
import hashlib
|
from pydantic import BaseModel
|
||||||
import json
|
|
||||||
from enum import Enum
|
|
||||||
from sqlite3 import Row
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from secp256k1 import PublicKey
|
|
||||||
|
|
||||||
|
|
||||||
class NostrRelay(BaseModel):
|
class BuyOrder(BaseModel):
|
||||||
id: str
|
action: str
|
||||||
name: str
|
relay_id: str
|
||||||
description: Optional[str]
|
|
||||||
pubkey: Optional[str]
|
|
||||||
contact: Optional[str] = "https://t.me/lnbits"
|
|
||||||
active: bool = False
|
|
||||||
supported_nips: List[str] = ["NIP01", "NIP09", "NIP11", "NIP15", "NIP20"]
|
|
||||||
software: Optional[str] = "LNbist"
|
|
||||||
version: Optional[str]
|
|
||||||
# meta: Optional[str]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_row(cls, row: Row) -> "NostrRelay":
|
|
||||||
return cls(**dict(row))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class NostrEventType(str, Enum):
|
|
||||||
EVENT = "EVENT"
|
|
||||||
REQ = "REQ"
|
|
||||||
CLOSE = "CLOSE"
|
|
||||||
|
|
||||||
|
|
||||||
class NostrEvent(BaseModel):
|
|
||||||
id: str
|
|
||||||
pubkey: str
|
pubkey: str
|
||||||
created_at: int
|
units_to_buy: int = 0
|
||||||
kind: int
|
|
||||||
tags: List[List[str]] = []
|
|
||||||
content: str = ""
|
|
||||||
sig: str
|
|
||||||
|
|
||||||
def serialize(self) -> List:
|
def is_valid_action(self) -> bool:
|
||||||
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
return self.action in ["join", "storage"]
|
||||||
|
|
||||||
def serialize_json(self) -> str:
|
|
||||||
e = self.serialize()
|
class NostrPartialAccount(BaseModel):
|
||||||
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
|
relay_id: str
|
||||||
|
pubkey: str
|
||||||
|
allowed: bool | None = None
|
||||||
|
blocked: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NostrAccount(BaseModel):
|
||||||
|
pubkey: str
|
||||||
|
relay_id: str
|
||||||
|
sats: int = 0
|
||||||
|
storage: int = 0
|
||||||
|
paid_to_join: bool = False
|
||||||
|
allowed: bool = False
|
||||||
|
blocked: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event_id(self) -> str:
|
def can_join(self):
|
||||||
data = self.serialize_json()
|
"""If an account is explicitly allowed then it does not need to pay"""
|
||||||
id = hashlib.sha256(data.encode()).hexdigest()
|
return self.paid_to_join or self.allowed
|
||||||
return id
|
|
||||||
|
|
||||||
def is_replaceable_event(self) -> bool:
|
|
||||||
return self.kind in [0, 3]
|
|
||||||
|
|
||||||
def is_delete_event(self) -> bool:
|
|
||||||
return self.kind == 5
|
|
||||||
|
|
||||||
def check_signature(self):
|
|
||||||
event_id = self.event_id
|
|
||||||
if self.id != event_id:
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
|
|
||||||
except Exception:
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
valid_signature = pub_key.schnorr_verify(
|
|
||||||
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
|
|
||||||
)
|
|
||||||
if not valid_signature:
|
|
||||||
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
|
|
||||||
|
|
||||||
def serialize_response(self, subscription_id):
|
|
||||||
return [NostrEventType.EVENT, subscription_id, dict(self)]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "NostrEvent":
|
def null_account(cls) -> "NostrAccount":
|
||||||
return cls(**dict(row))
|
return NostrAccount(pubkey="", relay_id="")
|
||||||
|
|
||||||
|
|
||||||
class NostrFilter(BaseModel):
|
class NostrEventTags(BaseModel):
|
||||||
subscription_id: Optional[str]
|
relay_id: str
|
||||||
|
event_id: str
|
||||||
ids: List[str] = []
|
name: str
|
||||||
authors: List[str] = []
|
value: str
|
||||||
kinds: List[int] = []
|
extra: str | None = None
|
||||||
e: List[str] = Field([], alias="#e")
|
|
||||||
p: List[str] = Field([], alias="#p")
|
|
||||||
since: Optional[int]
|
|
||||||
until: Optional[int]
|
|
||||||
limit: Optional[int]
|
|
||||||
|
|
||||||
def matches(self, e: NostrEvent) -> bool:
|
|
||||||
# todo: starts with
|
|
||||||
if len(self.ids) != 0 and e.id not in self.ids:
|
|
||||||
return False
|
|
||||||
if len(self.authors) != 0 and e.pubkey not in self.authors:
|
|
||||||
return False
|
|
||||||
if len(self.kinds) != 0 and e.kind not in self.kinds:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.since and e.created_at < self.since:
|
|
||||||
return False
|
|
||||||
if self.until and self.until > 0 and e.created_at > self.until:
|
|
||||||
return False
|
|
||||||
|
|
||||||
found_e_tag = self.tag_in_list(e.tags, "e")
|
|
||||||
found_p_tag = self.tag_in_list(e.tags, "p")
|
|
||||||
if not found_e_tag or not found_p_tag:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def tag_in_list(self, event_tags, tag_name) -> bool:
|
|
||||||
filter_tags = dict(self).get(tag_name, [])
|
|
||||||
if len(filter_tags) == 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
event_tag_values = [t[1] for t in event_tags if t[0] == tag_name]
|
|
||||||
|
|
||||||
common_tags = [
|
|
||||||
event_tag for event_tag in event_tag_values if event_tag in filter_tags
|
|
||||||
]
|
|
||||||
if len(common_tags) == 0:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_empty(self):
|
|
||||||
return (
|
|
||||||
len(self.ids) == 0
|
|
||||||
and len(self.authors) == 0
|
|
||||||
and len(self.kinds) == 0
|
|
||||||
and len(self.e) == 0
|
|
||||||
and len(self.p) == 0
|
|
||||||
and (not self.since)
|
|
||||||
and (not self.until)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
pyproject.toml
Normal file
91
pyproject.toml
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
[project]
|
||||||
|
name = "nostrrelay"
|
||||||
|
version = "1.1.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.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",
|
||||||
|
]
|
||||||
0
relay/__init__.py
Normal file
0
relay/__init__.py
Normal file
357
relay/client_connection.py
Normal file
357
relay/client_connection.py
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from ..crud import (
|
||||||
|
NostrAccount,
|
||||||
|
create_event,
|
||||||
|
delete_events,
|
||||||
|
get_account,
|
||||||
|
get_event,
|
||||||
|
get_events,
|
||||||
|
mark_events_deleted,
|
||||||
|
)
|
||||||
|
from .event import NostrEvent, NostrEventType
|
||||||
|
from .event_validator import EventValidator
|
||||||
|
from .filter import NostrFilter
|
||||||
|
from .relay import RelaySpec
|
||||||
|
|
||||||
|
|
||||||
|
class NostrClientConnection:
|
||||||
|
def __init__(self, relay_id: str, websocket: WebSocket):
|
||||||
|
self.websocket = websocket
|
||||||
|
self.relay_id = relay_id
|
||||||
|
self.filters: list[NostrFilter] = []
|
||||||
|
self.auth_pubkey: str | None = None # set if authenticated
|
||||||
|
self._auth_challenge: str | None = None
|
||||||
|
self._auth_challenge_created_at = 0
|
||||||
|
|
||||||
|
self.event_validator = EventValidator(self.relay_id)
|
||||||
|
|
||||||
|
self.broadcast_event: (
|
||||||
|
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] | None
|
||||||
|
) = None
|
||||||
|
self.get_client_config: Callable[[], RelaySpec] | None = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
await self.websocket.accept()
|
||||||
|
while True:
|
||||||
|
json_data = await self.websocket.receive_text()
|
||||||
|
try:
|
||||||
|
data = json.loads(json_data)
|
||||||
|
|
||||||
|
resp = await self._handle_message(data)
|
||||||
|
for r in resp:
|
||||||
|
await self._send_msg(r)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
async def stop(self, reason: str | None):
|
||||||
|
message = reason if reason else "Server closed webocket"
|
||||||
|
try:
|
||||||
|
await self._send_msg(["NOTICE", message])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.websocket.close(reason=reason)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def init_callbacks(self, broadcast_event: Callable, get_client_config: Callable):
|
||||||
|
self.broadcast_event = broadcast_event
|
||||||
|
self.get_client_config = get_client_config
|
||||||
|
self.event_validator.get_client_config = get_client_config
|
||||||
|
|
||||||
|
async def notify_event(self, event: NostrEvent) -> bool:
|
||||||
|
if self._is_private_event_for_other(event):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for nostr_filter in self.filters:
|
||||||
|
if nostr_filter.matches(event):
|
||||||
|
resp = event.serialize_response(nostr_filter.subscription_id)
|
||||||
|
await self._send_msg(resp)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"[NOSTRRELAY CLIENT] ❌ Filter didn't match for event {event.id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_private_event_for_other(self, event: NostrEvent) -> bool:
|
||||||
|
"""
|
||||||
|
p-tagged events that carry a single intended recipient (NIP-04 kind 4
|
||||||
|
direct messages and NIP-17 kind 1059 gift wraps) should not be
|
||||||
|
broadcast to arbitrary subscribers when the relay enforces AUTH for
|
||||||
|
that kind. Deliver only to the AUTH'd recipient named in a `p` tag.
|
||||||
|
"""
|
||||||
|
if not event.is_private_message:
|
||||||
|
return False
|
||||||
|
if not self.config.event_requires_auth(event.kind):
|
||||||
|
return False
|
||||||
|
if not self.auth_pubkey:
|
||||||
|
return True
|
||||||
|
if event.has_tag_value("p", self.auth_pubkey):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _broadcast_event(self, e: NostrEvent):
|
||||||
|
if self.broadcast_event:
|
||||||
|
await self.broadcast_event(self, e)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available for event {e.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_message(self, data: list) -> list:
|
||||||
|
if len(data) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
message_type = data[0]
|
||||||
|
|
||||||
|
if message_type == NostrEventType.EVENT:
|
||||||
|
event_dict = {
|
||||||
|
"relay_id": self.relay_id,
|
||||||
|
"publisher": data[1]["pubkey"],
|
||||||
|
**data[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
event = NostrEvent(**event_dict)
|
||||||
|
# Set the size field from the size_bytes property
|
||||||
|
event.size = event.size_bytes
|
||||||
|
await self._handle_event(event)
|
||||||
|
return []
|
||||||
|
if message_type == NostrEventType.REQ:
|
||||||
|
if len(data) < 3:
|
||||||
|
return []
|
||||||
|
subscription_id = data[1]
|
||||||
|
# Handle multiple filters in REQ message
|
||||||
|
# First remove existing filters for this subscription_id
|
||||||
|
self._remove_filter(subscription_id)
|
||||||
|
responses = []
|
||||||
|
for filter_data in data[2:]:
|
||||||
|
response = await self._handle_request(
|
||||||
|
subscription_id, NostrFilter.parse_obj(filter_data)
|
||||||
|
)
|
||||||
|
responses.extend(response)
|
||||||
|
return responses
|
||||||
|
if message_type == NostrEventType.CLOSE:
|
||||||
|
self._handle_close(data[1])
|
||||||
|
if message_type == NostrEventType.AUTH:
|
||||||
|
await self._handle_auth()
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _handle_event(self, e: NostrEvent):
|
||||||
|
logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']")
|
||||||
|
resp_nip20: list[Any] = ["OK", e.id]
|
||||||
|
|
||||||
|
if e.is_auth_response_event:
|
||||||
|
valid, message = self.event_validator.validate_auth_event(
|
||||||
|
e, self._auth_challenge
|
||||||
|
)
|
||||||
|
if not valid:
|
||||||
|
resp_nip20 += [valid, message]
|
||||||
|
await self._send_msg(resp_nip20)
|
||||||
|
return None
|
||||||
|
self.auth_pubkey = e.pubkey
|
||||||
|
|
||||||
|
if not self.auth_pubkey and self.config.event_requires_auth(e.kind):
|
||||||
|
await self._send_msg(["AUTH", self._current_auth_challenge()])
|
||||||
|
resp_nip20 += [
|
||||||
|
False,
|
||||||
|
f"Relay requires authentication for events of kind '{e.kind}'",
|
||||||
|
]
|
||||||
|
await self._send_msg(resp_nip20)
|
||||||
|
return None
|
||||||
|
|
||||||
|
publisher_pubkey = self.auth_pubkey if self.auth_pubkey else e.pubkey
|
||||||
|
valid, message = await self.event_validator.validate_write(e, publisher_pubkey)
|
||||||
|
if not valid:
|
||||||
|
resp_nip20 += [valid, message]
|
||||||
|
await self._send_msg(resp_nip20)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if e.is_replaceable_event:
|
||||||
|
await delete_events(
|
||||||
|
self.relay_id,
|
||||||
|
NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at),
|
||||||
|
)
|
||||||
|
if e.is_addressable_event:
|
||||||
|
# Extract 'd' tag value for addressable replacement (NIP-01)
|
||||||
|
d_tag_value = next((t[1] for t in e.tags if t[0] == "d"), None)
|
||||||
|
|
||||||
|
if d_tag_value:
|
||||||
|
deletion_filter = NostrFilter(
|
||||||
|
kinds=[e.kind],
|
||||||
|
authors=[e.pubkey],
|
||||||
|
**{"#d": [d_tag_value]}, # type: ignore
|
||||||
|
until=e.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_events(self.relay_id, deletion_filter)
|
||||||
|
if not e.is_ephemeral_event:
|
||||||
|
await create_event(e)
|
||||||
|
await self._broadcast_event(e)
|
||||||
|
|
||||||
|
if e.is_delete_event:
|
||||||
|
await self._handle_delete_event(e)
|
||||||
|
resp_nip20 += [True, ""]
|
||||||
|
except Exception as ex:
|
||||||
|
logger.debug(ex)
|
||||||
|
event = await get_event(self.relay_id, e.id)
|
||||||
|
# todo: handle NIP20 in detail
|
||||||
|
message = "error: failed to create event"
|
||||||
|
resp_nip20 += [event is not None, message]
|
||||||
|
|
||||||
|
await self._send_msg(resp_nip20)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> RelaySpec:
|
||||||
|
if not self.get_client_config:
|
||||||
|
raise Exception("Client not ready!")
|
||||||
|
return self.get_client_config()
|
||||||
|
|
||||||
|
async def _send_msg(self, data: list):
|
||||||
|
await self.websocket.send_text(json.dumps(data))
|
||||||
|
|
||||||
|
async def _handle_delete_event(self, event: NostrEvent):
|
||||||
|
# NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags)
|
||||||
|
|
||||||
|
# Get event IDs from 'e' tags (for regular events)
|
||||||
|
event_ids = [t[1] for t in event.tags if t[0] == "e"]
|
||||||
|
|
||||||
|
# Get event addresses from 'a' tags (for parameterized replaceable events)
|
||||||
|
event_addresses = [t[1] for t in event.tags if t[0] == "a"]
|
||||||
|
|
||||||
|
ids_to_delete = []
|
||||||
|
|
||||||
|
# Handle regular event deletions (e tags)
|
||||||
|
if event_ids:
|
||||||
|
nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids)
|
||||||
|
events_to_delete = await get_events(self.relay_id, nostr_filter, False)
|
||||||
|
ids_to_delete.extend(
|
||||||
|
[e.id for e in events_to_delete if not e.is_delete_event]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle parameterized replaceable event deletions (a tags)
|
||||||
|
if event_addresses:
|
||||||
|
for addr in event_addresses:
|
||||||
|
# Parse address format: kind:pubkey:d-tag
|
||||||
|
parts = addr.split(":")
|
||||||
|
if len(parts) == 3:
|
||||||
|
kind_str, addr_pubkey, d_tag = parts
|
||||||
|
try:
|
||||||
|
kind = int(kind_str)
|
||||||
|
# Only delete if the address pubkey matches the deletion event author
|
||||||
|
if addr_pubkey == event.pubkey:
|
||||||
|
# NOTE: Use "#d" alias, not "d" directly (Pydantic Field alias)
|
||||||
|
nostr_filter = NostrFilter(
|
||||||
|
authors=[addr_pubkey],
|
||||||
|
kinds=[kind],
|
||||||
|
**{"#d": [d_tag]}, # Use alias to set d field
|
||||||
|
)
|
||||||
|
events_to_delete = await get_events(
|
||||||
|
self.relay_id, nostr_filter, False
|
||||||
|
)
|
||||||
|
ids_to_delete.extend(
|
||||||
|
[
|
||||||
|
e.id
|
||||||
|
for e in events_to_delete
|
||||||
|
if not e.is_delete_event
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Deletion request pubkey mismatch: {addr_pubkey} != {event.pubkey}"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Invalid kind in address: {addr}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Invalid address format (expected kind:pubkey:d-tag): {addr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only mark events as deleted if we found specific IDs
|
||||||
|
if ids_to_delete:
|
||||||
|
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids_to_delete))
|
||||||
|
|
||||||
|
async def _handle_request(
|
||||||
|
self, subscription_id: str, nostr_filter: NostrFilter
|
||||||
|
) -> list:
|
||||||
|
if self.config.require_auth_filter:
|
||||||
|
if not self.auth_pubkey:
|
||||||
|
return [["AUTH", self._current_auth_challenge()]]
|
||||||
|
account = await get_account(self.relay_id, self.auth_pubkey)
|
||||||
|
if not account:
|
||||||
|
account = NostrAccount.null_account()
|
||||||
|
|
||||||
|
if account.blocked:
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
"NOTICE",
|
||||||
|
(
|
||||||
|
f"Public key '{self.auth_pubkey}' is not allowed "
|
||||||
|
f"in relay '{self.relay_id}'!"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
if not account.can_join and not self.config.is_free_to_join:
|
||||||
|
return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]]
|
||||||
|
|
||||||
|
nostr_filter.subscription_id = subscription_id
|
||||||
|
if not self._can_add_filter():
|
||||||
|
max_filters = self.config.max_client_filters
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
"NOTICE",
|
||||||
|
f"Maximum number of filters ({max_filters}) exceeded.",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
nostr_filter.enforce_limit(self.config.limit_per_filter)
|
||||||
|
self.filters.append(nostr_filter)
|
||||||
|
events = await get_events(self.relay_id, nostr_filter)
|
||||||
|
events = [e for e in events if not self._is_private_event_for_other(e)]
|
||||||
|
serialized_events = [
|
||||||
|
event.serialize_response(subscription_id) for event in events
|
||||||
|
]
|
||||||
|
resp_nip15 = ["EOSE", subscription_id]
|
||||||
|
serialized_events.append(resp_nip15)
|
||||||
|
return serialized_events
|
||||||
|
|
||||||
|
def _remove_filter(self, subscription_id: str):
|
||||||
|
self.filters = [f for f in self.filters if f.subscription_id != subscription_id]
|
||||||
|
|
||||||
|
def _handle_close(self, subscription_id: str):
|
||||||
|
self._remove_filter(subscription_id)
|
||||||
|
|
||||||
|
async def _handle_auth(self):
|
||||||
|
await self._send_msg(["AUTH", self._current_auth_challenge()])
|
||||||
|
|
||||||
|
def _can_add_filter(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.config.max_client_filters == 0
|
||||||
|
or len(self.filters) < self.config.max_client_filters
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_challenge_expired(self):
|
||||||
|
if self._auth_challenge_created_at == 0:
|
||||||
|
return True
|
||||||
|
current_time_seconds = round(time.time())
|
||||||
|
chanllenge_max_age_seconds = 300 # 5 min
|
||||||
|
return (
|
||||||
|
current_time_seconds - self._auth_challenge_created_at
|
||||||
|
) >= chanllenge_max_age_seconds
|
||||||
|
|
||||||
|
def _current_auth_challenge(self):
|
||||||
|
if self._auth_challenge_expired():
|
||||||
|
self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash()
|
||||||
|
self._auth_challenge_created_at = round(time.time())
|
||||||
|
return self._auth_challenge
|
||||||
73
relay/client_manager.py
Normal file
73
relay/client_manager.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
from ..crud import get_config_for_all_active_relays
|
||||||
|
from .client_connection import NostrClientConnection
|
||||||
|
from .event import NostrEvent
|
||||||
|
from .relay import RelaySpec
|
||||||
|
|
||||||
|
|
||||||
|
class NostrClientManager:
|
||||||
|
def __init__(self: "NostrClientManager"):
|
||||||
|
self._clients: dict = {}
|
||||||
|
self._active_relays: dict = {}
|
||||||
|
self._is_ready = False
|
||||||
|
|
||||||
|
async def add_client(self, c: NostrClientConnection) -> bool:
|
||||||
|
if not self._is_ready:
|
||||||
|
await self.init_relays()
|
||||||
|
|
||||||
|
if not (await self._allow_client(c)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._set_client_callbacks(c)
|
||||||
|
self.clients(c.relay_id).append(c)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_client(self, c: NostrClientConnection):
|
||||||
|
self.clients(c.relay_id).remove(c)
|
||||||
|
|
||||||
|
async def broadcast_event(self, source: NostrClientConnection, event: NostrEvent):
|
||||||
|
for client in self.clients(source.relay_id):
|
||||||
|
await client.notify_event(event)
|
||||||
|
|
||||||
|
async def init_relays(self):
|
||||||
|
self._active_relays = await get_config_for_all_active_relays()
|
||||||
|
self._is_ready = True
|
||||||
|
|
||||||
|
async def enable_relay(self, relay_id: str, config: RelaySpec):
|
||||||
|
self._is_ready = True
|
||||||
|
self._active_relays[relay_id] = config
|
||||||
|
|
||||||
|
async def disable_relay(self, relay_id: str):
|
||||||
|
await self._stop_clients_for_relay(relay_id)
|
||||||
|
if relay_id in self._active_relays:
|
||||||
|
del self._active_relays[relay_id]
|
||||||
|
|
||||||
|
def get_relay_config(self, relay_id: str) -> RelaySpec:
|
||||||
|
return self._active_relays[relay_id]
|
||||||
|
|
||||||
|
def clients(self, relay_id: str) -> list[NostrClientConnection]:
|
||||||
|
if relay_id not in self._clients:
|
||||||
|
self._clients[relay_id] = []
|
||||||
|
return self._clients[relay_id]
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
for relay_id in self._active_relays:
|
||||||
|
await self._stop_clients_for_relay(relay_id)
|
||||||
|
|
||||||
|
async def _stop_clients_for_relay(self, relay_id: str):
|
||||||
|
for client in self.clients(relay_id):
|
||||||
|
if client.relay_id == relay_id:
|
||||||
|
await client.stop(reason=f"Relay '{relay_id}' has been deactivated.")
|
||||||
|
|
||||||
|
async def _allow_client(self, c: NostrClientConnection) -> bool:
|
||||||
|
if c.relay_id not in self._active_relays:
|
||||||
|
await c.stop(reason=f"Relay '{c.relay_id}' is not active")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _set_client_callbacks(self, client: NostrClientConnection):
|
||||||
|
def get_client_config() -> RelaySpec:
|
||||||
|
return self.get_relay_config(client.relay_id)
|
||||||
|
|
||||||
|
client.get_client_config = get_client_config
|
||||||
|
client.init_callbacks(self.broadcast_event, get_client_config)
|
||||||
124
relay/event.py
Normal file
124
relay/event.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from coincurve import PublicKeyXOnly
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class NostrEventType(str, Enum):
|
||||||
|
EVENT = "EVENT"
|
||||||
|
REQ = "REQ"
|
||||||
|
CLOSE = "CLOSE"
|
||||||
|
AUTH = "AUTH"
|
||||||
|
|
||||||
|
|
||||||
|
class NostrEvent(BaseModel):
|
||||||
|
id: str
|
||||||
|
relay_id: str
|
||||||
|
publisher: str
|
||||||
|
pubkey: str
|
||||||
|
created_at: int
|
||||||
|
kind: int
|
||||||
|
tags: list[list[str]] = Field(default=[], no_database=True)
|
||||||
|
content: str = ""
|
||||||
|
sig: str
|
||||||
|
size: int = 0
|
||||||
|
|
||||||
|
def nostr_dict(self) -> dict:
|
||||||
|
_nostr_dict = dict(self)
|
||||||
|
_nostr_dict.pop("relay_id")
|
||||||
|
_nostr_dict.pop("publisher")
|
||||||
|
return _nostr_dict
|
||||||
|
|
||||||
|
def serialize(self) -> list:
|
||||||
|
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
||||||
|
|
||||||
|
def serialize_json(self) -> str:
|
||||||
|
e = self.serialize()
|
||||||
|
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_id(self) -> str:
|
||||||
|
data = self.serialize_json()
|
||||||
|
return hashlib.sha256(data.encode()).hexdigest()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_bytes(self) -> int:
|
||||||
|
s = json.dumps(self.nostr_dict(), separators=(",", ":"), ensure_ascii=False)
|
||||||
|
return len(s.encode())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_replaceable_event(self) -> bool:
|
||||||
|
return self.kind in [0, 3, 41] or (self.kind >= 10000 and self.kind < 20000)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_auth_response_event(self) -> bool:
|
||||||
|
return self.kind == 22242
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_direct_message(self) -> bool:
|
||||||
|
return self.kind == 4
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_delete_event(self) -> bool:
|
||||||
|
return self.kind == 5
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_seal(self) -> bool:
|
||||||
|
return self.kind == 13
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_gift_wrap(self) -> bool:
|
||||||
|
return self.kind == 1059
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_private_message(self) -> bool:
|
||||||
|
# Kinds whose payload addresses a single recipient via a `p` tag and is
|
||||||
|
# not meant to be broadcast to other subscribers when AUTH is enforced.
|
||||||
|
# NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap).
|
||||||
|
return self.is_direct_message or self.is_gift_wrap
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_regular_event(self) -> bool:
|
||||||
|
return self.kind >= 1000 and self.kind < 10000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_ephemeral_event(self) -> bool:
|
||||||
|
return self.kind >= 20000 and self.kind < 30000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_addressable_event(self) -> bool:
|
||||||
|
return self.kind >= 30000 and self.kind < 40000
|
||||||
|
|
||||||
|
def check_signature(self):
|
||||||
|
event_id = self.event_id
|
||||||
|
if self.id != event_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pub_key = PublicKeyXOnly(bytes.fromhex(self.pubkey))
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
valid_signature = pub_key.verify(
|
||||||
|
bytes.fromhex(self.sig),
|
||||||
|
bytes.fromhex(event_id),
|
||||||
|
)
|
||||||
|
if not valid_signature:
|
||||||
|
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
|
||||||
|
|
||||||
|
def serialize_response(self, subscription_id):
|
||||||
|
return [NostrEventType.EVENT, subscription_id, self.nostr_dict()]
|
||||||
|
|
||||||
|
def tag_values(self, tag_name: str) -> list[str]:
|
||||||
|
return [t[1] for t in self.tags if t[0] == tag_name]
|
||||||
|
|
||||||
|
def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
|
||||||
|
return tag_value in self.tag_values(tag_name)
|
||||||
|
|
||||||
|
def is_direct_message_for_pubkey(self, pubkey: str) -> bool:
|
||||||
|
return self.is_direct_message and self.has_tag_value("p", pubkey)
|
||||||
135
relay/event_validator.py
Normal file
135
relay/event_validator.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from ..crud import get_account, get_storage_for_public_key, prune_old_events
|
||||||
|
from ..helpers import extract_domain
|
||||||
|
from ..models import NostrAccount
|
||||||
|
from .event import NostrEvent
|
||||||
|
from .relay import RelaySpec
|
||||||
|
|
||||||
|
|
||||||
|
class EventValidator:
|
||||||
|
def __init__(self, relay_id: str):
|
||||||
|
self.relay_id = relay_id
|
||||||
|
|
||||||
|
self._last_event_timestamp = 0 # in hours
|
||||||
|
self._event_count_per_timestamp = 0
|
||||||
|
|
||||||
|
self.get_client_config: Callable[[], RelaySpec] | None = None
|
||||||
|
|
||||||
|
async def validate_write(
|
||||||
|
self, e: NostrEvent, publisher_pubkey: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
valid, message = self._validate_event(e)
|
||||||
|
if not valid:
|
||||||
|
return (valid, message)
|
||||||
|
|
||||||
|
if e.is_ephemeral_event:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
valid, message = await self._validate_storage(publisher_pubkey, e.size_bytes)
|
||||||
|
if not valid:
|
||||||
|
return (valid, message)
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_auth_event(
|
||||||
|
self, e: NostrEvent, auth_challenge: str | None
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
valid, message = self._validate_event(e)
|
||||||
|
if not valid:
|
||||||
|
return (valid, message)
|
||||||
|
|
||||||
|
relay_tag = e.tag_values("relay")
|
||||||
|
challenge_tag = e.tag_values("challenge")
|
||||||
|
if len(relay_tag) == 0 or len(challenge_tag) == 0:
|
||||||
|
return False, "error: NIP42 tags are missing for auth event"
|
||||||
|
|
||||||
|
if self.config.domain != extract_domain(relay_tag[0]):
|
||||||
|
return False, "error: wrong relay domain for auth event"
|
||||||
|
|
||||||
|
if auth_challenge != challenge_tag[0]:
|
||||||
|
return False, "error: wrong chanlange value for auth event"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> RelaySpec:
|
||||||
|
if not self.get_client_config:
|
||||||
|
raise Exception("EventValidator not ready!")
|
||||||
|
return self.get_client_config()
|
||||||
|
|
||||||
|
def _validate_event(self, e: NostrEvent) -> tuple[bool, str]:
|
||||||
|
if self._exceeded_max_events_per_hour():
|
||||||
|
return False, "Exceeded max events per hour limit'!"
|
||||||
|
|
||||||
|
try:
|
||||||
|
e.check_signature()
|
||||||
|
except ValueError:
|
||||||
|
return False, "invalid: wrong event `id` or `sig`"
|
||||||
|
|
||||||
|
in_range, message = self._created_at_in_range(e.created_at)
|
||||||
|
if not in_range:
|
||||||
|
return False, message
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
async def _validate_storage(
|
||||||
|
self, pubkey: str, event_size_bytes: int
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if self.config.is_read_only_relay:
|
||||||
|
return False, "Cannot write event, relay is read-only"
|
||||||
|
|
||||||
|
account = await get_account(self.relay_id, pubkey)
|
||||||
|
if not account:
|
||||||
|
account = NostrAccount.null_account()
|
||||||
|
|
||||||
|
if account.blocked:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Public key '{pubkey}' is not allowed in relay '{self.relay_id}'!",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not account.can_join and not self.config.is_free_to_join:
|
||||||
|
return False, f"This is a paid relay: '{self.relay_id}'"
|
||||||
|
|
||||||
|
stored_bytes = await get_storage_for_public_key(self.relay_id, pubkey)
|
||||||
|
total_available_storage = account.storage + self.config.free_storage_bytes_value
|
||||||
|
if (stored_bytes + event_size_bytes) <= total_available_storage:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if self.config.full_storage_action == "block":
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Cannot write event, no storage available for public key: '{pubkey}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_size_bytes > total_available_storage:
|
||||||
|
return False, "Message is too large. Not enough storage available for it."
|
||||||
|
|
||||||
|
await prune_old_events(self.relay_id, pubkey, event_size_bytes)
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def _exceeded_max_events_per_hour(self) -> bool:
|
||||||
|
if self.config.max_events_per_hour == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_time = round(time.time() / 3600)
|
||||||
|
if self._last_event_timestamp == current_time:
|
||||||
|
self._event_count_per_timestamp += 1
|
||||||
|
else:
|
||||||
|
self._last_event_timestamp = current_time
|
||||||
|
self._event_count_per_timestamp = 0
|
||||||
|
|
||||||
|
return self._event_count_per_timestamp > self.config.max_events_per_hour
|
||||||
|
|
||||||
|
def _created_at_in_range(self, created_at: int) -> tuple[bool, str]:
|
||||||
|
current_time = round(time.time())
|
||||||
|
if self.config.created_at_in_past != 0:
|
||||||
|
if created_at < (current_time - self.config.created_at_in_past):
|
||||||
|
return False, "created_at is too much into the past"
|
||||||
|
if self.config.created_at_in_future != 0:
|
||||||
|
if created_at > (current_time + self.config.created_at_in_future):
|
||||||
|
return False, "created_at is too much into the future"
|
||||||
|
return True, ""
|
||||||
123
relay/filter.py
Normal file
123
relay/filter.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .event import NostrEvent
|
||||||
|
|
||||||
|
|
||||||
|
class NostrFilter(BaseModel):
|
||||||
|
e: list[str] = Field(default=[], alias="#e")
|
||||||
|
p: list[str] = Field(default=[], alias="#p")
|
||||||
|
d: list[str] = Field(default=[], alias="#d")
|
||||||
|
ids: list[str] = []
|
||||||
|
authors: list[str] = []
|
||||||
|
kinds: list[int] = []
|
||||||
|
subscription_id: str | None = None
|
||||||
|
since: int | None = None
|
||||||
|
until: int | None = None
|
||||||
|
limit: int | None = None
|
||||||
|
|
||||||
|
def matches(self, e: NostrEvent) -> bool:
|
||||||
|
# todo: starts with
|
||||||
|
if len(self.ids) != 0 and e.id not in self.ids:
|
||||||
|
return False
|
||||||
|
if len(self.authors) != 0 and e.pubkey not in self.authors:
|
||||||
|
return False
|
||||||
|
if len(self.kinds) != 0 and e.kind not in self.kinds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.since and e.created_at < self.since:
|
||||||
|
return False
|
||||||
|
if self.until and self.until > 0 and e.created_at > self.until:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check tag filters - only fail if filter is specified and no match found
|
||||||
|
if not self.tag_in_list(e.tags, "e"):
|
||||||
|
return False
|
||||||
|
if not self.tag_in_list(e.tags, "p"):
|
||||||
|
return False
|
||||||
|
if not self.tag_in_list(e.tags, "d"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tag_in_list(self, event_tags, tag_name) -> bool:
|
||||||
|
filter_tags = dict(self).get(tag_name, [])
|
||||||
|
if len(filter_tags) == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
event_tag_values = [t[1] for t in event_tags if t[0] == tag_name]
|
||||||
|
|
||||||
|
common_tags = [
|
||||||
|
event_tag for event_tag in event_tag_values if event_tag in filter_tags
|
||||||
|
]
|
||||||
|
if len(common_tags) == 0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_empty(self):
|
||||||
|
return (
|
||||||
|
len(self.ids) == 0
|
||||||
|
and len(self.authors) == 0
|
||||||
|
and len(self.kinds) == 0
|
||||||
|
and len(self.e) == 0
|
||||||
|
and len(self.p) == 0
|
||||||
|
and (not self.since)
|
||||||
|
and (not self.until)
|
||||||
|
)
|
||||||
|
|
||||||
|
def enforce_limit(self, limit: int):
|
||||||
|
if not self.limit or self.limit > limit:
|
||||||
|
self.limit = limit
|
||||||
|
|
||||||
|
def to_sql_components(self, relay_id: str) -> tuple[list[str], list[str], dict]:
|
||||||
|
inner_joins: list[str] = []
|
||||||
|
where = ["deleted=false", "nostrrelay.events.relay_id = :relay_id"]
|
||||||
|
values: dict = {"relay_id": relay_id}
|
||||||
|
|
||||||
|
if len(self.e):
|
||||||
|
e_s = ",".join([f"'{e}'" for e in self.e])
|
||||||
|
inner_joins.append(
|
||||||
|
"INNER JOIN nostrrelay.event_tags e_tags "
|
||||||
|
"ON nostrrelay.events.id = e_tags.event_id"
|
||||||
|
)
|
||||||
|
where.append(f" (e_tags.value in ({e_s}) AND e_tags.name = 'e')")
|
||||||
|
|
||||||
|
if len(self.p):
|
||||||
|
p_s = ",".join([f"'{p}'" for p in self.p])
|
||||||
|
inner_joins.append(
|
||||||
|
"INNER JOIN nostrrelay.event_tags p_tags "
|
||||||
|
"ON nostrrelay.events.id = p_tags.event_id"
|
||||||
|
)
|
||||||
|
where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'")
|
||||||
|
|
||||||
|
if len(self.d):
|
||||||
|
d_s = ",".join([f"'{d}'" for d in self.d])
|
||||||
|
d_join = (
|
||||||
|
"INNER JOIN nostrrelay.event_tags d_tags "
|
||||||
|
"ON nostrrelay.events.id = d_tags.event_id"
|
||||||
|
)
|
||||||
|
d_where = f" d_tags.value in ({d_s}) AND d_tags.name = 'd'"
|
||||||
|
|
||||||
|
inner_joins.append(d_join)
|
||||||
|
where.append(d_where)
|
||||||
|
|
||||||
|
if len(self.ids) != 0:
|
||||||
|
ids = ",".join([f"'{_id}'" for _id in self.ids])
|
||||||
|
where.append(f"id IN ({ids})")
|
||||||
|
|
||||||
|
if len(self.authors) != 0:
|
||||||
|
authors = ",".join([f"'{author}'" for author in self.authors])
|
||||||
|
where.append(f"pubkey IN ({authors})")
|
||||||
|
|
||||||
|
if len(self.kinds) != 0:
|
||||||
|
kinds = ",".join([f"'{kind}'" for kind in self.kinds])
|
||||||
|
where.append(f"kind IN ({kinds})")
|
||||||
|
|
||||||
|
if self.since:
|
||||||
|
where.append("created_at >= :since")
|
||||||
|
values["since"] = self.since
|
||||||
|
|
||||||
|
if self.until:
|
||||||
|
where.append("created_at < :until")
|
||||||
|
values["until"] = self.until
|
||||||
|
|
||||||
|
return inner_joins, where, values
|
||||||
122
relay/relay.py
Normal file
122
relay/relay.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Spec(BaseModel):
|
||||||
|
class Config:
|
||||||
|
allow_population_by_field_name = True
|
||||||
|
|
||||||
|
|
||||||
|
class FilterSpec(Spec):
|
||||||
|
max_client_filters: int = Field(default=0, alias="maxClientFilters")
|
||||||
|
limit_per_filter: int = Field(default=1000, alias="limitPerFilter")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSpec(Spec):
|
||||||
|
max_events_per_hour: int = Field(default=0, alias="maxEventsPerHour")
|
||||||
|
|
||||||
|
created_at_days_past: int = Field(default=0, alias="createdAtDaysPast")
|
||||||
|
created_at_hours_past: int = Field(default=0, alias="createdAtHoursPast")
|
||||||
|
created_at_minutes_past: int = Field(default=0, alias="createdAtMinutesPast")
|
||||||
|
created_at_seconds_past: int = Field(default=0, alias="createdAtSecondsPast")
|
||||||
|
|
||||||
|
created_at_days_future: int = Field(default=0, alias="createdAtDaysFuture")
|
||||||
|
created_at_hours_future: int = Field(default=0, alias="createdAtHoursFuture")
|
||||||
|
created_at_minutes_future: int = Field(default=0, alias="createdAtMinutesFuture")
|
||||||
|
created_at_seconds_future: int = Field(default=0, alias="createdAtSecondsFuture")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_in_past(self) -> int:
|
||||||
|
return (
|
||||||
|
self.created_at_days_past * 86400
|
||||||
|
+ self.created_at_hours_past * 3600
|
||||||
|
+ self.created_at_minutes_past * 60
|
||||||
|
+ self.created_at_seconds_past
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_in_future(self) -> int:
|
||||||
|
return (
|
||||||
|
self.created_at_days_future * 86400
|
||||||
|
+ self.created_at_hours_future * 3600
|
||||||
|
+ self.created_at_minutes_future * 60
|
||||||
|
+ self.created_at_seconds_future
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageSpec(Spec):
|
||||||
|
free_storage_value: int = Field(default=1, alias="freeStorageValue")
|
||||||
|
free_storage_unit: str = Field(default="MB", alias="freeStorageUnit")
|
||||||
|
full_storage_action: str = Field(default="prune", alias="fullStorageAction")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def free_storage_bytes_value(self):
|
||||||
|
value = self.free_storage_value * 1024
|
||||||
|
if self.free_storage_unit == "MB":
|
||||||
|
value *= 1024
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSpec(Spec):
|
||||||
|
require_auth_events: bool = Field(default=False, alias="requireAuthEvents")
|
||||||
|
skiped_auth_events: list = Field(default=[], alias="skipedAuthEvents")
|
||||||
|
forced_auth_events: list = Field(default=[], alias="forcedAuthEvents")
|
||||||
|
require_auth_filter: bool = Field(default=False, alias="requireAuthFilter")
|
||||||
|
|
||||||
|
def event_requires_auth(self, kind: int) -> bool:
|
||||||
|
if self.require_auth_events:
|
||||||
|
return kind not in self.skiped_auth_events
|
||||||
|
return kind in self.forced_auth_events
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSpec(Spec):
|
||||||
|
is_paid_relay: bool = Field(default=False, alias="isPaidRelay")
|
||||||
|
cost_to_join: int = Field(default=0, alias="costToJoin")
|
||||||
|
|
||||||
|
storage_cost_value: int = Field(default=0, alias="storageCostValue")
|
||||||
|
storage_cost_unit: str = Field(default="MB", alias="storageCostUnit")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_free_to_join(self):
|
||||||
|
return not self.is_paid_relay or self.cost_to_join == 0
|
||||||
|
|
||||||
|
|
||||||
|
class WalletSpec(Spec):
|
||||||
|
wallet: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
|
||||||
|
domain: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_read_only_relay(self):
|
||||||
|
return self.free_storage_value == 0 and not self.is_paid_relay
|
||||||
|
|
||||||
|
|
||||||
|
class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NostrRelay(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str | None = None
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
pubkey: str | None = None
|
||||||
|
contact: str | None = None
|
||||||
|
active: bool = False
|
||||||
|
meta: RelaySpec = RelaySpec()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_free_to_join(self):
|
||||||
|
return not self.meta.is_paid_relay or self.meta.cost_to_join == 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def info(
|
||||||
|
cls,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"contact": "https://t.me/lnbits",
|
||||||
|
"supported_nips": [1, 2, 4, 9, 11, 15, 16, 17, 20, 22, 28, 42, 44, 59],
|
||||||
|
"software": "LNbits",
|
||||||
|
"version": "",
|
||||||
|
}
|
||||||
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 +0,0 @@
|
||||||
<div>xxx</div>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
async function relayDetails(path) {
|
|
||||||
const template = await loadTemplateAsync(path)
|
|
||||||
Vue.component('relay-details', {
|
|
||||||
name: 'relay-details',
|
|
||||||
template,
|
|
||||||
|
|
||||||
props: [],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
formDialogItem: {
|
|
||||||
show: false,
|
|
||||||
data: {
|
|
||||||
name: '',
|
|
||||||
description: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
satBtc(val, showUnit = true) {
|
|
||||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
created: async function () {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
BIN
static/image/1.png
Normal file
BIN
static/image/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
static/image/2.png
Normal file
BIN
static/image/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
static/image/3.png
Normal file
BIN
static/image/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
static/image/4.png
Normal file
BIN
static/image/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
static/image/5.png
Normal file
BIN
static/image/5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
static/image/6.png
Normal file
BIN
static/image/6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -1,74 +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: '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: '',
|
||||||
|
|
@ -77,114 +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',
|
||||||
console.log('### relayLinks', this.relayLinks)
|
label: 'Active',
|
||||||
} catch (error) {
|
field: ''
|
||||||
console.log('### getRelays', error)
|
},
|
||||||
LNbits.utils.notifyApiError(error)
|
{
|
||||||
|
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) {
|
|
||||||
console.log('### showToggleRelayDialog', 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 () => {
|
|
||||||
console.log('#### onCancel')
|
|
||||||
relay.active = !relay.active
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleRelay: async function (relay) {
|
|
||||||
console.log('### toggleRelay', relay)
|
|
||||||
try {
|
|
||||||
const response = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
'/nostrrelay/api/v1/relay/' + relay.id,
|
|
||||||
this.g.user.wallets[0].adminkey,
|
|
||||||
relay
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteRelay: function (relayId) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
'All data will be lost! Are you sure you want to delete this relay?'
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
const response = await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
'/nostrrelay/api/v1/relay/' + relayId,
|
|
||||||
this.g.user.wallets[0].adminkey
|
|
||||||
)
|
|
||||||
|
|
||||||
this.relayLinks = _.reject(this.relayLinks, function (obj) {
|
|
||||||
return obj.id === relayId
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
sendFormDataRelay: async function () {
|
|
||||||
console.log('### sendFormDataRelay')
|
|
||||||
this.createRelay(this.formDialogRelay.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
99
tasks.py
Normal file
99
tasks.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.services import websocket_updater
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .crud import create_account, get_account, update_account
|
||||||
|
from .models import NostrAccount
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue, "ext_nostrrelay")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment):
|
||||||
|
if payment.extra.get("tag") != "nostrrely":
|
||||||
|
return
|
||||||
|
|
||||||
|
relay_id = payment.extra.get("relay_id")
|
||||||
|
pubkey = payment.extra.get("pubkey")
|
||||||
|
payment_hash = payment.payment_hash
|
||||||
|
|
||||||
|
if not relay_id or not pubkey:
|
||||||
|
message = (
|
||||||
|
"Invoice extra data missing for 'relay_id' and 'pubkey'. "
|
||||||
|
f"Payment hash: {payment_hash}"
|
||||||
|
)
|
||||||
|
logger.warning(message)
|
||||||
|
await websocket_updater(
|
||||||
|
payment_hash, json.dumps({"success": False, "message": message})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
action = payment.extra.get("action")
|
||||||
|
if action == "join":
|
||||||
|
await invoice_paid_to_join(relay_id, pubkey, payment.amount)
|
||||||
|
await websocket_updater(payment_hash, json.dumps({"success": True}))
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "storage":
|
||||||
|
storage_to_buy = payment.extra.get("storage_to_buy")
|
||||||
|
if not storage_to_buy:
|
||||||
|
message = (
|
||||||
|
"Invoice extra data missing for 'storage_to_buy'. "
|
||||||
|
f"Payment hash: {payment_hash}"
|
||||||
|
)
|
||||||
|
logger.warning(message)
|
||||||
|
return
|
||||||
|
await invoice_paid_for_storage(relay_id, pubkey, storage_to_buy, payment.amount)
|
||||||
|
await websocket_updater(payment_hash, json.dumps({"success": True}))
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket_updater(
|
||||||
|
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):
|
||||||
|
account = await get_account(relay_id, pubkey)
|
||||||
|
if not account:
|
||||||
|
account = NostrAccount(
|
||||||
|
relay_id=relay_id, pubkey=pubkey, paid_to_join=True, sats=amount
|
||||||
|
)
|
||||||
|
await create_account(account)
|
||||||
|
return
|
||||||
|
|
||||||
|
if account.blocked or account.paid_to_join:
|
||||||
|
return
|
||||||
|
|
||||||
|
account.paid_to_join = True
|
||||||
|
account.sats += amount
|
||||||
|
await update_account(account)
|
||||||
|
|
||||||
|
|
||||||
|
async def invoice_paid_for_storage(
|
||||||
|
relay_id: str, pubkey: str, storage_to_buy: int, amount: int
|
||||||
|
):
|
||||||
|
account = await get_account(relay_id, pubkey)
|
||||||
|
if not account:
|
||||||
|
account = NostrAccount(
|
||||||
|
relay_id=relay_id, pubkey=pubkey, storage=storage_to_buy, sats=amount
|
||||||
|
)
|
||||||
|
await create_account(account)
|
||||||
|
return
|
||||||
|
|
||||||
|
if account.blocked:
|
||||||
|
return
|
||||||
|
|
||||||
|
account.storage = storage_to_buy
|
||||||
|
account.sats += amount
|
||||||
|
await update_account(account)
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -68,54 +68,56 @@
|
||||||
/>
|
/>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
|
<q-td key="id" :props="props">
|
||||||
<q-td auto-width> {{props.row.name}} </q-td>
|
<a
|
||||||
<q-td key="description" :props="props">
|
style="color: unset"
|
||||||
{{props.row.description}}
|
:href="props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
v-text="props.row.id"
|
||||||
|
></a>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="pubkey" :props="props">
|
<q-td key="toggle" :props="props">
|
||||||
<div>{{props.row.pubkey}}</div>
|
<q-toggle
|
||||||
</q-td>
|
size="sm"
|
||||||
<q-td key="contact" :props="props">
|
color="secodary"
|
||||||
<div>{{props.row.contact}}</div>
|
v-model="props.row.active"
|
||||||
|
@update:model-value="showToggleRelayDialog(props.row)"
|
||||||
|
></q-toggle>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
<q-td auto-width v-text="props.row.name"></q-td>
|
||||||
|
<q-td
|
||||||
|
key="description"
|
||||||
|
:props="props"
|
||||||
|
v-text="props.row.description"
|
||||||
|
></q-td>
|
||||||
|
<q-td
|
||||||
|
key="pubkey"
|
||||||
|
:props="props"
|
||||||
|
v-text="props.row.pubkey"
|
||||||
|
></q-td>
|
||||||
|
<q-td
|
||||||
|
key="contact"
|
||||||
|
:props="props"
|
||||||
|
v-text="props.row.contact"
|
||||||
|
></q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
<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%">
|
||||||
<div class="row items-center q-mt-md q-mb-lg">
|
<div class="row items-center q-mb-lg">
|
||||||
<div class="col-6 q-pr-lg">
|
<div class="col-12">
|
||||||
<q-toggle
|
|
||||||
:label="props.row.active ? 'Activated': 'Deactivated' "
|
|
||||||
color="secodary"
|
|
||||||
v-model="props.row.active"
|
|
||||||
@input="showToggleRelayDialog(props.row)"
|
|
||||||
></q-toggle>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 q-pr-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="pink"
|
|
||||||
icon="cancel"
|
|
||||||
class="float-right"
|
|
||||||
@click="deleteRelay(props.row.id)"
|
|
||||||
>Delete Relay</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center q-mt-md q-mb-lg">
|
|
||||||
<div class="col-2 q-pr-lg"></div>
|
|
||||||
<div class="col-10 q-pr-lg">
|
|
||||||
<relay-details
|
<relay-details
|
||||||
:relay-id="props.row.id"
|
:relay-id="props.row.id"
|
||||||
:adminkey="g.user.wallets[0].adminkey"
|
:adminkey="g.user.wallets[0].adminkey"
|
||||||
:inkey="g.user.wallets[0].inkey"
|
:inkey="g.user.wallets[0].inkey"
|
||||||
|
:wallet-options="g.user.walletOptions"
|
||||||
|
@relay-deleted="handleRelayDeleted"
|
||||||
|
@relay-updated="handleRelayUpdated"
|
||||||
></relay-details>
|
></relay-details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
@ -194,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 %}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,284 @@
|
||||||
{% extends "public.html" %} {% block toolbar_title %} {{ nostrrelay.name }}
|
{% extends "public.html" %} {% block toolbar_title %} LNbits Relay
|
||||||
<q-btn
|
<q-icon name="sensors" class="q-ml-lg" />
|
||||||
flat
|
|
||||||
dense
|
|
||||||
size="md"
|
|
||||||
@click.prevent="urlDialog.show = true"
|
|
||||||
icon="share"
|
|
||||||
color="white"
|
|
||||||
></q-btn>
|
|
||||||
{% endblock %} {% block footer %}{% endblock %} {% block page_container %}
|
{% endblock %} {% block footer %}{% endblock %} {% block page_container %}
|
||||||
|
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
<q-page>
|
<q-page>
|
||||||
<h3>Shareable public page on relay to go here!</h3>
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-2 q-gutter-y-md"></div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md q-pa-xl">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h4 v-text="relay.name" class="q-my-none"></h4>
|
||||||
|
</q-card-section>
|
||||||
|
<div v-if="relay.description" class="q-ma-lg">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<span class="text-subtitle1" v-text="relay.description"></span>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
<q-card class="q-pb-xl">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2 q-pt-sm">
|
||||||
|
<span class="text-bold">Relay Link:</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
v-model.trim="wssLink"
|
||||||
|
type="text"
|
||||||
|
label="Relay Link"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
class="float-right"
|
||||||
|
@click="copyText(wssLink)"
|
||||||
|
>Copy</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-if="relay.meta.isPaidRelay">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2 q-pt-sm">
|
||||||
|
<span class="text-bold">Public Key:</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="pubkey"
|
||||||
|
type="text"
|
||||||
|
label="User Public Key"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-2"></div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-if="relay.meta.isPaidRelay">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<span class="text-bold">Cost to join: </span>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div>
|
||||||
|
<span v-text="relay.meta.costToJoin"></span>
|
||||||
|
<span class="text-bold q-ml-sm">sats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div v-if="relay.meta.costToJoin">
|
||||||
|
<q-btn
|
||||||
|
@click="createInvoice('join')"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
|
>Pay to Join</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-badge color="green" class="float-right"
|
||||||
|
><span>Free to join</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-if="relay.meta.isPaidRelay">
|
||||||
|
<div class="row q-mt-md q-mb-md">
|
||||||
|
<div class="col-2 q-pt-sm">
|
||||||
|
<span class="text-bold">Storage cost: </span>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 q-pt-sm">
|
||||||
|
<span v-text="relay.meta.storageCostValue"></span>
|
||||||
|
<span class="text-bold q-ml-sm"> sats per</span>
|
||||||
|
<q-badge color="orange">
|
||||||
|
<span v-text="relay.meta.storageCostUnit"></span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<q-input
|
||||||
|
v-if="relay.meta.storageCostValue"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="unitsToBuy"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
:label="relay.meta.storageCostUnit"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pt-sm">
|
||||||
|
<div v-if="relay.meta.storageCostValue">
|
||||||
|
<span class="text-bold q-ml-md" v-text="storageCost"></span>
|
||||||
|
<span>sats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div v-if="relay.meta.storageCostValue">
|
||||||
|
<q-btn
|
||||||
|
@click="createInvoice('storage')"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
|
>Buy storage space</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-badge color="green" class="float-right"
|
||||||
|
><span>Free storage</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-else>
|
||||||
|
<q-badge color="yellow" text-color="black">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
This is a free Nostr Relay
|
||||||
|
</h5>
|
||||||
|
</q-badge>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-expansion-item
|
||||||
|
v-if="invoice"
|
||||||
|
group="join-invoice"
|
||||||
|
label="Invoice"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
default-opened
|
||||||
|
>
|
||||||
|
<div class="row q-ma-md">
|
||||||
|
<div class="col-3"></div>
|
||||||
|
<div class="col-6 text-center">
|
||||||
|
<q-btn outline color="grey" @click="copyText(invoice)"
|
||||||
|
>Copy invoice</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3"></div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-responsive :ratio="1">
|
||||||
|
<qrcode
|
||||||
|
:value="'lightning:'+invoice"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</div>
|
||||||
|
<div class="col-3"></div>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item v-else-if="invoiceResponse">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3"></div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-icon
|
||||||
|
v-if="invoiceResponse.success"
|
||||||
|
name="check"
|
||||||
|
style="color: green; font-size: 21.4em"
|
||||||
|
></q-icon>
|
||||||
|
<span v-else v-text="invoiceResponse.message"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-3"></div>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</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}}'),
|
||||||
|
pubkey: '',
|
||||||
|
invoice: '',
|
||||||
|
invoiceResponse: null,
|
||||||
|
unitsToBuy: 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {}
|
computed: {
|
||||||
|
storageCost: function () {
|
||||||
|
if (!this.relay || !this.relay.meta.storageCostValue) return 0
|
||||||
|
return this.unitsToBuy * this.relay.meta.storageCostValue
|
||||||
|
},
|
||||||
|
wssLink: function () {
|
||||||
|
this.relay.meta.domain =
|
||||||
|
this.relay.meta.domain || window.location.hostname
|
||||||
|
return (
|
||||||
|
'wss://' + this.relay.meta.domain + '/nostrrelay/' + this.relay.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createInvoice: async function (action) {
|
||||||
|
if (!action) return
|
||||||
|
this.invoice = ''
|
||||||
|
if (!this.pubkey) {
|
||||||
|
this.$q.notify({
|
||||||
|
timeout: 5000,
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Public key is missing'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const reqData = {
|
||||||
|
action,
|
||||||
|
relay_id: this.relay.id,
|
||||||
|
pubkey: this.pubkey,
|
||||||
|
units_to_buy: this.unitsToBuy
|
||||||
|
}
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/nostrrelay/api/v1/pay',
|
||||||
|
'',
|
||||||
|
reqData
|
||||||
|
)
|
||||||
|
this.invoice = data.invoice
|
||||||
|
const paymentHashTag = decode(data.invoice).data.tags.find(
|
||||||
|
t => t && t.description === 'payment_hash'
|
||||||
|
)
|
||||||
|
if (paymentHashTag) {
|
||||||
|
await this.waitForPaidInvoice(paymentHashTag.value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForPaidInvoice: function (paymentHash) {
|
||||||
|
try {
|
||||||
|
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
|
||||||
|
const port = location.port ? `:${location.port}` : ''
|
||||||
|
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${paymentHash}`
|
||||||
|
const wsConnection = new WebSocket(wsUrl)
|
||||||
|
wsConnection.onmessage = e => {
|
||||||
|
this.invoiceResponse = JSON.parse(e.data)
|
||||||
|
this.invoice = null
|
||||||
|
wsConnection.close()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
timeout: 5000,
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Failed to get invoice status',
|
||||||
|
caption: `${error}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
681
templates/nostrrelay/relay-details.html
Normal file
681
templates/nostrrelay/relay-details.html
Normal file
|
|
@ -0,0 +1,681 @@
|
||||||
|
<div>
|
||||||
|
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
|
||||||
|
<q-tab name="info" label="Relay Info"></q-tab>
|
||||||
|
<q-tab name="payment" label="Payment"></q-tab>
|
||||||
|
<q-tab name="config" label="Config"></q-tab>
|
||||||
|
<q-tab name="accounts" label="Accounts"></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
<q-tab-panels v-model="tab">
|
||||||
|
<q-tab-panel name="info">
|
||||||
|
<div v-if="relay">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Name:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.name"
|
||||||
|
type="text"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Description:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.description"
|
||||||
|
type="text"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Relay Public Key:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.pubkey"
|
||||||
|
type="text"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Contact:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.contact"
|
||||||
|
type="text"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Domain:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.domain"
|
||||||
|
type="text"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Web Socket Link:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="wssLink"
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1">
|
||||||
|
<q-btn outline color="grey" @click="copyText(wssLink)">Copy</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="payment">
|
||||||
|
<div v-if="relay">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg q-pb-md">Free Storage:</div>
|
||||||
|
<div class="col-md-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.freeStorageValue"
|
||||||
|
type="number"
|
||||||
|
hint="Value"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.freeStorageUnit"
|
||||||
|
type="text"
|
||||||
|
hint="Unit"
|
||||||
|
:options="storageUnits"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 q-pr-lg">
|
||||||
|
<q-icon name="info" class="cursor-pointer q-pb-md">
|
||||||
|
<q-tooltip>
|
||||||
|
How much data a client can store. This can be extended with the
|
||||||
|
Paid Plan.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-2">
|
||||||
|
<q-badge
|
||||||
|
v-if="relay.meta.freeStorageValue == 0"
|
||||||
|
color="orange"
|
||||||
|
class="float-right q-mb-md"
|
||||||
|
><span>No free storage</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Paid Plan:</div>
|
||||||
|
<div class="col-md-3 q-pr-lg">
|
||||||
|
<q-toggle
|
||||||
|
color="secodary"
|
||||||
|
v-model="relay.meta.isPaidRelay"
|
||||||
|
@update:model-value="togglePaidRelay"
|
||||||
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-badge
|
||||||
|
v-if="!relay.meta.isPaidRelay && relay.meta.freeStorageValue == 0"
|
||||||
|
color="orange"
|
||||||
|
class="float-right q-mb-md"
|
||||||
|
><span>No data will be stored. Read-only relay.</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="relay.meta.isPaidRelay && relay.meta.wallet">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Wallet:</div>
|
||||||
|
<div class="col-md-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="relay.meta.wallet"
|
||||||
|
:options="walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-1">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
Wallet where the paiments will be sent to.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Cost to join (sats):</div>
|
||||||
|
<div class="col-md-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.costToJoin"
|
||||||
|
type="number"
|
||||||
|
hint="sats"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 q-pr-lg">
|
||||||
|
<q-icon name="info" class="cursor-pointer q-pb-md">
|
||||||
|
<q-tooltip>
|
||||||
|
Ask a fee for clients to join. Expected to be paid only once.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-4">
|
||||||
|
<q-badge
|
||||||
|
v-if="relay.meta.costToJoin == 0"
|
||||||
|
color="green"
|
||||||
|
class="float-right"
|
||||||
|
><span>Free to join</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Storage Cost (sats):</div>
|
||||||
|
<div class="col-md-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.storageCostValue"
|
||||||
|
type="number"
|
||||||
|
hint="sats"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.storageCostUnit"
|
||||||
|
type="text"
|
||||||
|
hint="Unit"
|
||||||
|
:options="storageUnits"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 q-pr-lg">
|
||||||
|
<q-icon name="info" class="cursor-pointer q-pb-md">
|
||||||
|
<q-tooltip>
|
||||||
|
Cost for clients to buy additional storage.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-0">
|
||||||
|
<q-badge
|
||||||
|
v-if="relay.meta.storageCostValue == 0"
|
||||||
|
color="green"
|
||||||
|
class="float-right"
|
||||||
|
><span>Unlimited storage</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="config">
|
||||||
|
<div v-if="relay">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Created At in Past:</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.createdAtDaysPast"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
hint="Days"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.createdAtHoursPast"
|
||||||
|
type="number"
|
||||||
|
hint="Hours"
|
||||||
|
:options="hours"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.createdAtMinutesPast"
|
||||||
|
type="number"
|
||||||
|
hint="Minutes"
|
||||||
|
:options="range60"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.createdAtSecondsPast"
|
||||||
|
type="number"
|
||||||
|
hint="Seconds"
|
||||||
|
:options="range60"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 q-pb-md">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
NIP 22: Lower limit within which a relay will consider an
|
||||||
|
event's created_at to be acceptable.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Created At in Future:</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.createdAtDaysFuture"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
hint="Days"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.createdAtHoursFuture"
|
||||||
|
type="number"
|
||||||
|
hint="Hours"
|
||||||
|
:options="hours"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.createdAtMinutesFuture"
|
||||||
|
type="number"
|
||||||
|
hint="Minutes"
|
||||||
|
:options="range60"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="relay.meta.createdAtSecondsFuture"
|
||||||
|
type="number"
|
||||||
|
hint="Seconds"
|
||||||
|
:options="range60"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 q-pb-md">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
NIP 22: Upper limit within which a relay will consider an
|
||||||
|
event's created_at to be acceptable.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row items-center no-wrap q-mb-md q-mt-md">
|
||||||
|
<div class="col-3 q-pr-lg">Require Auth :</div>
|
||||||
|
<div class="col-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-toggle
|
||||||
|
color="secodary"
|
||||||
|
class="q-ml-md q-mr-md"
|
||||||
|
v-model="relay.meta.requireAuthFilter"
|
||||||
|
>For Filters</q-toggle
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 col-sm-4 q-pr-lg">
|
||||||
|
<q-toggle
|
||||||
|
color="secodary"
|
||||||
|
class="q-ml-md q-mr-md"
|
||||||
|
v-model="relay.meta.requireAuthEvents"
|
||||||
|
>For All Events</q-toggle
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 col-sm-5">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
Require client authentication for accessing different types of
|
||||||
|
resources.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="relay.meta.requireAuthEvents"
|
||||||
|
class="row items-center no-wrap q-mb-md q-mt-md"
|
||||||
|
>
|
||||||
|
<div class="col-3 q-pr-lg">Skip Auth For Events:</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="skipEventKind"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="secondary"
|
||||||
|
icon="add"
|
||||||
|
@click="addSkipAuthForEvent()"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-7">
|
||||||
|
<q-chip
|
||||||
|
v-for="e in relay.meta.skipedAuthEvents"
|
||||||
|
:key="e"
|
||||||
|
removable
|
||||||
|
@remove="removeSkipAuthForEvent(e)"
|
||||||
|
color="primary"
|
||||||
|
text-color="white"
|
||||||
|
>
|
||||||
|
<span v-text="e"></span>
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="row items-center no-wrap q-mb-md q-mt-md">
|
||||||
|
<div class="col-3 q-pr-lg">Force Auth For Events:</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="forceEventKind"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="secondary"
|
||||||
|
icon="add"
|
||||||
|
@click="addForceAuthForEvent()"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-7">
|
||||||
|
<q-chip
|
||||||
|
v-for="e in relay.meta.forcedAuthEvents"
|
||||||
|
:key="e"
|
||||||
|
removable
|
||||||
|
@remove="removeForceAuthForEvent(e)"
|
||||||
|
color="primary"
|
||||||
|
text-color="white"
|
||||||
|
>
|
||||||
|
<span v-text="e"></span>
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row items-center no-wrap q-mb-md q-mt-md">
|
||||||
|
<div class="col-3 q-pr-lg">Full Storage Action:</div>
|
||||||
|
<div class="col-3 col-sm-4 q-pr-lg">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="relay.meta.fullStorageAction"
|
||||||
|
type="text"
|
||||||
|
:options="fullStorageActions"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-5">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
Action to be taken when the storage limit (if any) has been
|
||||||
|
reached.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Limit per filter:</div>
|
||||||
|
<div class="col-3 col-sm-4 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.limitPerFilter"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-5">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
Maximum number of events to be returned in the initial query
|
||||||
|
(default 1000)
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
<q-badge
|
||||||
|
v-if="relay.meta.limitPerFilter == 0"
|
||||||
|
color="green"
|
||||||
|
class="float-right"
|
||||||
|
><span>No Limit</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Max Filters (per client):</div>
|
||||||
|
<div class="col-3 col-sm-4 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.maxClientFilters"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-5">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
Limit the number of filters that a client can have. Prevents
|
||||||
|
relay from being abused by clients with extremly high number of
|
||||||
|
fiters.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
<q-badge
|
||||||
|
v-if="relay.meta.maxClientFilters == 0"
|
||||||
|
color="green"
|
||||||
|
class="float-right"
|
||||||
|
><span>Unlimited Filters</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Max events per hour:</div>
|
||||||
|
<div class="col-3 col-sm-4 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="relay.meta.maxEventsPerHour"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-5">
|
||||||
|
<q-icon name="info" class="cursor-pointer">
|
||||||
|
<q-tooltip>
|
||||||
|
Limits the rate at which events are accepted by the relay.
|
||||||
|
Prevent clients from clogging the relay.
|
||||||
|
</q-tooltip></q-icon
|
||||||
|
>
|
||||||
|
<q-badge
|
||||||
|
v-if="relay.meta.maxEventsPerHour == 0"
|
||||||
|
color="green"
|
||||||
|
class="float-right"
|
||||||
|
><span>No Limit</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="accounts">
|
||||||
|
<div v-if="relay">
|
||||||
|
<q-card class="q-mr-lg q-mb-md"
|
||||||
|
><q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-2 q-pr-lg">Public Key:</div>
|
||||||
|
<div class="col-5 q-pr-lg">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="accountPubkey"
|
||||||
|
type="text"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-md">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="green"
|
||||||
|
class="float-left"
|
||||||
|
@click="allowPublicKey(accountPubkey, true)"
|
||||||
|
>Allow</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="pink"
|
||||||
|
class="float-left"
|
||||||
|
@click="blockPublicKey(accountPubkey, true)"
|
||||||
|
>Block</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card class="q-mr-lg q-mb-dm"
|
||||||
|
><q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Filter:</div>
|
||||||
|
<div class="col-9 q-pr-lg">
|
||||||
|
<q-toggle
|
||||||
|
size="sm"
|
||||||
|
color="secodary"
|
||||||
|
class="q-mr-lg"
|
||||||
|
v-model="showAllowedAccounts"
|
||||||
|
@update:model-value="getAccounts()"
|
||||||
|
>Show Allowed Account</q-toggle
|
||||||
|
>
|
||||||
|
<q-toggle
|
||||||
|
size="sm"
|
||||||
|
color="secodary"
|
||||||
|
class="q-mr-lg"
|
||||||
|
v-model="showBlockedAccounts"
|
||||||
|
@update:model-value="getAccounts()"
|
||||||
|
>
|
||||||
|
Show Blocked Accounts</q-toggle
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section></q-card
|
||||||
|
>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-12 q-pr-lg">
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:rows="accounts"
|
||||||
|
row-key="pubkey"
|
||||||
|
:columns="accountsTable.columns"
|
||||||
|
:pagination.sync="accountsTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td key="action" :props="props">
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
color="pink"
|
||||||
|
class="float-right"
|
||||||
|
@click="removePublicKey(props.row.pubkey)"
|
||||||
|
size="sm"
|
||||||
|
>Delete</q-btn
|
||||||
|
>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="pubkey" :props="props">
|
||||||
|
<span v-text="props.row.pubkey"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="allowed" :props="props">
|
||||||
|
<q-toggle
|
||||||
|
size="sm"
|
||||||
|
color="secodary"
|
||||||
|
v-model="props.row.allowed"
|
||||||
|
@update:model-value="togglePublicKey(props.row, 'allow')"
|
||||||
|
></q-toggle>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="blocked" :props="props">
|
||||||
|
<q-toggle
|
||||||
|
size="sm"
|
||||||
|
color="secodary"
|
||||||
|
v-model="props.row.blocked"
|
||||||
|
@update:model-value="togglePublicKey(props.row, 'block')"
|
||||||
|
></q-toggle>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td auto-width
|
||||||
|
><span v-text="props.row.paid_to_join"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width> <span v-text="props.row.sats"></span></q-td>
|
||||||
|
<q-td auto-width
|
||||||
|
><span v-text="props.row.storage"></span>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
<div class="row items-center q-mt-md q-mb-lg">
|
||||||
|
<div class="col-6 q-pr-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="secondary"
|
||||||
|
class="float-left"
|
||||||
|
@click="updateRelay()"
|
||||||
|
>Update Relay</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="pink"
|
||||||
|
icon="cancel"
|
||||||
|
class="float-right"
|
||||||
|
@click="deleteRelay()"
|
||||||
|
>Delete Relay</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
53
tests/conftest.py
Normal file
53
tests/conftest.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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():
|
||||||
|
db = Database("ext_nostrrelay")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS nostrrelay.events;")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS nostrrelay.relays;")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS nostrrelay.event_tags;")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS nostrrelay.accounts;")
|
||||||
|
|
||||||
|
# check if exists else skip migrations
|
||||||
|
for key, migrate in inspect.getmembers(migrations, inspect.isfunction):
|
||||||
|
logger.info(f"Running migration '{key}'.")
|
||||||
|
await migrate(db)
|
||||||
|
|
||||||
|
yield 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"]]
|
||||||
|
|
@ -1,384 +1,365 @@
|
||||||
{
|
{
|
||||||
"alice": {
|
"alice": {
|
||||||
"meta": [
|
"meta": [
|
||||||
"EVENT",
|
"EVENT",
|
||||||
{
|
{
|
||||||
"id": "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
|
"id": "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
|
||||||
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
||||||
"created_at": 1675332095,
|
"created_at": 1675332095,
|
||||||
"kind": 0,
|
"kind": 0,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"content": "{\"name\":\"Alice\"}",
|
"content": "{\"name\":\"Alice\"}",
|
||||||
"sig": "95c30b6bbc70f3777d2b2b47ae3961e196eae0df72f3ae301ff1009cdabf9c50bb0eb7825891c842fc6ca5cb268342cc486850a6127ab40df871bd3e1fd0b0d7"
|
"sig": "95c30b6bbc70f3777d2b2b47ae3961e196eae0df72f3ae301ff1009cdabf9c50bb0eb7825891c842fc6ca5cb268342cc486850a6127ab40df871bd3e1fd0b0d7"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"meta_response": [
|
||||||
|
"OK",
|
||||||
|
"9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"meta_update": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a",
|
||||||
|
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
||||||
|
"created_at": 1675673494,
|
||||||
|
"kind": 0,
|
||||||
|
"tags": [],
|
||||||
|
"content": "{\"name\":\"Alice\",\"about\":\"Uses Hamstr\"}",
|
||||||
|
"sig": "938313418d6d8b16b43213b3347c64925cbc1846e4447b4d878be9b865fe4b78f276ac399ea6b0aa81ed88fb18c992f2fae9e4f70c35c49202e576c54a0dc89c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta_update_response": [
|
||||||
|
"OK",
|
||||||
|
"2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"post01": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
|
||||||
|
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
||||||
|
"created_at": 1675332224,
|
||||||
|
"kind": 1,
|
||||||
|
"tags": [],
|
||||||
|
"content": "Alice - post 01",
|
||||||
|
"sig": "8d27c9f818ff194b491de1dc7d52d2d26916d87189ed1330315c4ff5509a986c80f34c2202302f8fe246c0b3f4e2f79103c000cbd6ca65bbe3921e14f30cb35b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post01_response_ok": [
|
||||||
|
"OK",
|
||||||
|
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"post01_response_duplicate": [
|
||||||
|
"OK",
|
||||||
|
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
|
||||||
|
true,
|
||||||
|
"error: failed to create event"
|
||||||
|
],
|
||||||
|
"post02": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5",
|
||||||
|
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
||||||
|
"created_at": 1675332284,
|
||||||
|
"kind": 1,
|
||||||
|
"tags": [],
|
||||||
|
"content": "Alice post 02",
|
||||||
|
"sig": "012fc88407b0cfb967e80d1117acf6cf03410f6810039543d2290eef64e246d82ad130d08814b2564cee68e77dd0e99ea539e7a9751ef2e0914e7d93f345094e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post02_response_ok": [
|
||||||
|
"OK",
|
||||||
|
"79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"subscribe_reactions_to_me": [
|
||||||
|
"REQ",
|
||||||
|
"notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345",
|
||||||
|
{
|
||||||
|
"kinds": [1, 7, 6, 4],
|
||||||
|
"#p": [
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
],
|
],
|
||||||
"meta_response": [
|
"limit": 400
|
||||||
"OK",
|
}
|
||||||
"9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
|
],
|
||||||
true,
|
"direct_message01": [
|
||||||
""
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225",
|
||||||
|
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
||||||
|
"created_at": 1675412967,
|
||||||
|
"kind": 4,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a"
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"meta_update": [
|
"content": "BwstXDkQJAHnLOrWFzBRDHdMoF4hoXSCwgmR+K2uw237yss/i639rpR2iOIYJP4z?iv=5pTRQh6NBKfe1hyhwh2WEw==",
|
||||||
"EVENT",
|
"sig": "5da31b8a51dcc9fc9665db6199084696b705fc415e1be684b82fe39f3cbd271c2d707fd5a532232205a016e99ed1ef12abdacb52d139d7f5746cb693de71e5aa"
|
||||||
{
|
}
|
||||||
"id": "2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a",
|
],
|
||||||
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
"direct_message01_response": [
|
||||||
"created_at": 1675673494,
|
"OK",
|
||||||
"kind": 0,
|
"28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225",
|
||||||
"tags": [],
|
true,
|
||||||
"content": "{\"name\":\"Alice\",\"about\":\"Uses Hamstr\"}",
|
""
|
||||||
"sig": "938313418d6d8b16b43213b3347c64925cbc1846e4447b4d878be9b865fe4b78f276ac399ea6b0aa81ed88fb18c992f2fae9e4f70c35c49202e576c54a0dc89c"
|
],
|
||||||
}
|
"delete_post01": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"kind": 5,
|
||||||
|
"content": "deleted",
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"e",
|
||||||
|
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85"
|
||||||
|
],
|
||||||
|
["e", "mock-id", ""],
|
||||||
|
[
|
||||||
|
"e",
|
||||||
|
"bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea"
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"meta_update_response": [
|
"created_at": 1675427798,
|
||||||
"OK",
|
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
||||||
"2928f73760ac3a60affdf51d04169680472a8594b4584f087f497dcf6a28d12a",
|
"id": "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093",
|
||||||
true,
|
"sig": "8e972ba7f1ce9d11ba5d49fdd48db4a92ea999790eb604e6a7f01868a26a70a8e96e1f9e104d8f77a5aa7f29e94119e33117b4cc8a5ff9e50ec8c23eeccd94e9"
|
||||||
""
|
}
|
||||||
],
|
],
|
||||||
"post01": [
|
"delete_post01_response": [
|
||||||
"EVENT",
|
"OK",
|
||||||
{
|
"2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093",
|
||||||
"id": "05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
|
true,
|
||||||
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
""
|
||||||
"created_at": 1675332224,
|
],
|
||||||
"kind": 1,
|
"subscribe_to_bob_contact_list": [
|
||||||
"tags": [],
|
"REQ",
|
||||||
"content": "Alice - post 01",
|
"contact",
|
||||||
"sig": "8d27c9f818ff194b491de1dc7d52d2d26916d87189ed1330315c4ff5509a986c80f34c2202302f8fe246c0b3f4e2f79103c000cbd6ca65bbe3921e14f30cb35b"
|
{
|
||||||
}
|
"kinds": [3],
|
||||||
],
|
"authors": [
|
||||||
"post01_response_ok": [
|
"d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a"
|
||||||
"OK",
|
|
||||||
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"post01_response_duplicate": [
|
|
||||||
"OK",
|
|
||||||
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
|
|
||||||
true,
|
|
||||||
"error: failed to create event"
|
|
||||||
],
|
|
||||||
"post02": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5",
|
|
||||||
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
|
||||||
"created_at": 1675332284,
|
|
||||||
"kind": 1,
|
|
||||||
"tags": [],
|
|
||||||
"content": "Alice post 02",
|
|
||||||
"sig": "012fc88407b0cfb967e80d1117acf6cf03410f6810039543d2290eef64e246d82ad130d08814b2564cee68e77dd0e99ea539e7a9751ef2e0914e7d93f345094e"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"post02_response_ok": [
|
|
||||||
"OK",
|
|
||||||
"79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"subscribe_reactions_to_me": [
|
|
||||||
"REQ",
|
|
||||||
"notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345",
|
|
||||||
{
|
|
||||||
"kinds": [
|
|
||||||
1,
|
|
||||||
7,
|
|
||||||
6,
|
|
||||||
4
|
|
||||||
],
|
|
||||||
"#p": [
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
],
|
|
||||||
"limit": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"direct_message01": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225",
|
|
||||||
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
|
||||||
"created_at": 1675412967,
|
|
||||||
"kind": 4,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "BwstXDkQJAHnLOrWFzBRDHdMoF4hoXSCwgmR+K2uw237yss/i639rpR2iOIYJP4z?iv=5pTRQh6NBKfe1hyhwh2WEw==",
|
|
||||||
"sig": "5da31b8a51dcc9fc9665db6199084696b705fc415e1be684b82fe39f3cbd271c2d707fd5a532232205a016e99ed1ef12abdacb52d139d7f5746cb693de71e5aa"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"direct_message01_response": [
|
|
||||||
"OK",
|
|
||||||
"28c96b6e80681c18a690e0e0dc6ca4e72b9d291d1d2576bc8949a07bb4bee225",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"delete_post01": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"kind": 5,
|
|
||||||
"content": "deleted",
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"e",
|
|
||||||
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"e",
|
|
||||||
"mock-id",
|
|
||||||
""
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"e",
|
|
||||||
"bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"created_at": 1675427798,
|
|
||||||
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
|
|
||||||
"id": "2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093",
|
|
||||||
"sig": "8e972ba7f1ce9d11ba5d49fdd48db4a92ea999790eb604e6a7f01868a26a70a8e96e1f9e104d8f77a5aa7f29e94119e33117b4cc8a5ff9e50ec8c23eeccd94e9"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"delete_post01_response": [
|
|
||||||
"OK",
|
|
||||||
"2751f2ee0f894268c61300c5b1a1a434f49a33a467a6f4516f10a82a1848f093",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"subscribe_to_bob_contact_list": [
|
|
||||||
"REQ",
|
|
||||||
"contact",
|
|
||||||
{
|
|
||||||
"kinds": [
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
"d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"bob": {
|
]
|
||||||
"meta": [
|
},
|
||||||
"EVENT",
|
"bob": {
|
||||||
{
|
"meta": [
|
||||||
"id": "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
|
"EVENT",
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
{
|
||||||
"created_at": 1675332410,
|
"id": "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
|
||||||
"kind": 0,
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
"tags": [],
|
"created_at": 1675332410,
|
||||||
"content": "{\"name\":\"Bob\"}",
|
"kind": 0,
|
||||||
"sig": "52b142eb5bf95e46424d8f146a0efcfd1be35ec2ae446152ccc875bc82eee66bef6df1af9a4456ec8984540ac4e21905544b5291334e2b18a24e534b788b2d81"
|
"tags": [],
|
||||||
}
|
"content": "{\"name\":\"Bob\"}",
|
||||||
],
|
"sig": "52b142eb5bf95e46424d8f146a0efcfd1be35ec2ae446152ccc875bc82eee66bef6df1af9a4456ec8984540ac4e21905544b5291334e2b18a24e534b788b2d81"
|
||||||
"meta_response": [
|
}
|
||||||
"OK",
|
],
|
||||||
"a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
|
"meta_response": [
|
||||||
true,
|
"OK",
|
||||||
""
|
"a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
|
||||||
],
|
true,
|
||||||
"request_meta_alice": [
|
""
|
||||||
"REQ",
|
],
|
||||||
"profile",
|
"request_meta_alice": [
|
||||||
{
|
"REQ",
|
||||||
"kinds": [
|
"profile",
|
||||||
0
|
{
|
||||||
],
|
"kinds": [0],
|
||||||
"authors": [
|
"authors": [
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request_posts_alice": [
|
|
||||||
"REQ",
|
|
||||||
"sub0",
|
|
||||||
{
|
|
||||||
"kinds": [
|
|
||||||
1
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
],
|
|
||||||
"limit": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"like_post01": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "700da4df9029a049ddecd1c586b778f434afb55e56c3016d94334108e3829db7",
|
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
|
||||||
"created_at": 1675350162,
|
|
||||||
"kind": 7,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"e",
|
|
||||||
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "\u2764\ufe0f",
|
|
||||||
"sig": "3522d670f2e28bd63d32184aa9617df360684e5bc4b7c791b53c5401437e1bf91d1d335f016076fdee9afa99046dc9cc06a39738b25ff9a1562ac7321e3dca2e"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"like_post02": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9",
|
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
|
||||||
"created_at": 1675332450,
|
|
||||||
"kind": 7,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"e",
|
|
||||||
"79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "\u2764\ufe0f",
|
|
||||||
"sig": "90fa8093088ed9280277f10a97c41d68d9f51d24254f7b27c28f5d84ac25426f1bfc217bca0c6712a9965164b07db219ee7e583b94c4d26f00aee87344c3f17a"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"like_post02_response": [
|
|
||||||
"OK",
|
|
||||||
"920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"comment_on_alice_post01": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea",
|
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
|
||||||
"created_at": 1675332468,
|
|
||||||
"kind": 1,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"e",
|
|
||||||
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "bob comment 01",
|
|
||||||
"sig": "f9bb53e2adc27f3a49ec42d681833742e28d734327107ebba3076be226340503048116947a75274e5262fa03aa0430da6fe697e46e19342639ef208e5690d8c5"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"comment_on_alice_post01_response": [
|
|
||||||
"OK",
|
|
||||||
"bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"direct_message01": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2",
|
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
|
||||||
"created_at": 1675412687,
|
|
||||||
"kind": 4,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "jjBSyp36t555ywERY2fI4A==?iv=+8bg2vsltrXewAywxw9m6w==",
|
|
||||||
"sig": "091f4e8e5c497099bfe6af58126e022bc8babe648b8157c39f51e5d3906bfddf01f2f6d1a3ed36f94fbf07b009008fd448fbb8ce35b60260517aa0124a6c5c39"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"direct_message01_response": [
|
|
||||||
"OK",
|
|
||||||
"15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"subscribe_to_direct_messages": [
|
|
||||||
"REQ",
|
|
||||||
"notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed",
|
|
||||||
{
|
|
||||||
"kinds": [
|
|
||||||
4
|
|
||||||
],
|
|
||||||
"#p": [
|
|
||||||
"d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a"
|
|
||||||
],
|
|
||||||
"limit": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"subscribe_to_delete_from_alice": [
|
|
||||||
"REQ",
|
|
||||||
"notifications:delete",
|
|
||||||
{
|
|
||||||
"kinds": [
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
],
|
|
||||||
"limit": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"contact_list_create": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62",
|
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
|
||||||
"created_at": 1675350109,
|
|
||||||
"kind": 3,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "",
|
|
||||||
"sig": "740972ce0335fe6be7194c995e407e440b4194e49ee2775a19dc36eb5e9d8302ea8d0ab93cdc11eb345a9f8bae32c14bcbd4b7f3fe9b97d197b8426dba139847"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"contact_list_create_response": [
|
|
||||||
"OK",
|
|
||||||
"141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"contact_list_update": [
|
|
||||||
"EVENT",
|
|
||||||
{
|
|
||||||
"id": "1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40",
|
|
||||||
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
|
||||||
"created_at": 1675444161,
|
|
||||||
"kind": 3,
|
|
||||||
"tags": [
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"p",
|
|
||||||
"8d21cd7c3f204cbb8aaf7708445b49e6cef7da23a550f9a27d21b1122c0cb4e9"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"content": "",
|
|
||||||
"sig": "648230464f6b79063da76c1c9d06cd290c65f95fca4bac2e055f84f003847a4b9a2e144b4d77ec9f2f5289d477353a21494548d1b1fbf8795602c8914a062d50"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"contact_list_update_response": [
|
|
||||||
"OK",
|
|
||||||
"1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40",
|
|
||||||
true,
|
|
||||||
""
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
|
"request_posts_alice": [
|
||||||
|
"REQ",
|
||||||
|
"sub0",
|
||||||
|
{
|
||||||
|
"kinds": [1],
|
||||||
|
"authors": [
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
],
|
||||||
|
"limit": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"like_post01": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "700da4df9029a049ddecd1c586b778f434afb55e56c3016d94334108e3829db7",
|
||||||
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
|
"created_at": 1675350162,
|
||||||
|
"kind": 7,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"e",
|
||||||
|
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"content": "\u2764\ufe0f",
|
||||||
|
"sig": "3522d670f2e28bd63d32184aa9617df360684e5bc4b7c791b53c5401437e1bf91d1d335f016076fdee9afa99046dc9cc06a39738b25ff9a1562ac7321e3dca2e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"like_post02": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9",
|
||||||
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
|
"created_at": 1675332450,
|
||||||
|
"kind": 7,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"e",
|
||||||
|
"79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"content": "\u2764\ufe0f",
|
||||||
|
"sig": "90fa8093088ed9280277f10a97c41d68d9f51d24254f7b27c28f5d84ac25426f1bfc217bca0c6712a9965164b07db219ee7e583b94c4d26f00aee87344c3f17a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"like_post02_response": [
|
||||||
|
"OK",
|
||||||
|
"920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"comment_on_alice_post01": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea",
|
||||||
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
|
"created_at": 1675332468,
|
||||||
|
"kind": 1,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"e",
|
||||||
|
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"content": "bob comment 01",
|
||||||
|
"sig": "f9bb53e2adc27f3a49ec42d681833742e28d734327107ebba3076be226340503048116947a75274e5262fa03aa0430da6fe697e46e19342639ef208e5690d8c5"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"comment_on_alice_post01_response": [
|
||||||
|
"OK",
|
||||||
|
"bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"direct_message01": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2",
|
||||||
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
|
"created_at": 1675412687,
|
||||||
|
"kind": 4,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"content": "jjBSyp36t555ywERY2fI4A==?iv=+8bg2vsltrXewAywxw9m6w==",
|
||||||
|
"sig": "091f4e8e5c497099bfe6af58126e022bc8babe648b8157c39f51e5d3906bfddf01f2f6d1a3ed36f94fbf07b009008fd448fbb8ce35b60260517aa0124a6c5c39"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direct_message01_response": [
|
||||||
|
"OK",
|
||||||
|
"15f6e6bd6cb538167d4430ea6bd7c0cfb99b400ca3e8879a114e90f74b3f20b2",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"subscribe_to_direct_messages": [
|
||||||
|
"REQ",
|
||||||
|
"notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed",
|
||||||
|
{
|
||||||
|
"kinds": [4],
|
||||||
|
"#p": [
|
||||||
|
"d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a"
|
||||||
|
],
|
||||||
|
"limit": 400
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subscribe_to_delete_from_alice": [
|
||||||
|
"REQ",
|
||||||
|
"notifications:delete",
|
||||||
|
{
|
||||||
|
"kinds": [5],
|
||||||
|
"authors": [
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
],
|
||||||
|
"limit": 400
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contact_list_create": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62",
|
||||||
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
|
"created_at": 1675350109,
|
||||||
|
"kind": 3,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"content": "",
|
||||||
|
"sig": "740972ce0335fe6be7194c995e407e440b4194e49ee2775a19dc36eb5e9d8302ea8d0ab93cdc11eb345a9f8bae32c14bcbd4b7f3fe9b97d197b8426dba139847"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contact_list_create_response": [
|
||||||
|
"OK",
|
||||||
|
"141ddb3008ed1cc35fa09ff88d3b82da0351c6166c566e6220293136aa902a62",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"contact_list_update": [
|
||||||
|
"EVENT",
|
||||||
|
{
|
||||||
|
"id": "1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40",
|
||||||
|
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
|
||||||
|
"created_at": 1675444161,
|
||||||
|
"kind": 3,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"8d21cd7c3f204cbb8aaf7708445b49e6cef7da23a550f9a27d21b1122c0cb4e9"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"content": "",
|
||||||
|
"sig": "648230464f6b79063da76c1c9d06cd290c65f95fca4bac2e055f84f003847a4b9a2e144b4d77ec9f2f5289d477353a21494548d1b1fbf8795602c8914a062d50"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contact_list_update_response": [
|
||||||
|
"OK",
|
||||||
|
"1439f08983433295bc54d24b8c3cda2fa137d86636535a408d2d9a7bac5f0c40",
|
||||||
|
true,
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,220 +1,239 @@
|
||||||
{
|
{
|
||||||
"valid": [
|
"valid": [
|
||||||
{
|
{
|
||||||
"name": "kind 0, metadata",
|
"name": "kind 0, metadata",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
|
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
|
||||||
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
"relay_id": "r1",
|
||||||
"created_at": 1675242172,
|
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"kind": 0,
|
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"tags": [],
|
"created_at": 1675242172,
|
||||||
"content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}",
|
"kind": 0,
|
||||||
"sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f"
|
"tags": [],
|
||||||
}
|
"content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}",
|
||||||
},
|
"sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f"
|
||||||
{
|
}
|
||||||
"name": "kind 1, no tags",
|
},
|
||||||
"data": {
|
{
|
||||||
"kind": 1,
|
"name": "kind 1, no tags",
|
||||||
"content": "i126",
|
"data": {
|
||||||
"tags": [],
|
"kind": 1,
|
||||||
"created_at": 1675239988,
|
"content": "i126",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"tags": [],
|
||||||
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
"created_at": 1675239988,
|
||||||
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
|
"relay_id": "r1",
|
||||||
}
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
},
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
{
|
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
||||||
"name": "kind 1, reply, e & p tags",
|
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
|
||||||
"data": {
|
}
|
||||||
"kind": 1,
|
},
|
||||||
"content": "i126 reply",
|
{
|
||||||
"tags": [
|
"name": "kind 1, reply, e & p tags",
|
||||||
[
|
"data": {
|
||||||
"e",
|
"kind": 1,
|
||||||
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
"content": "i126 reply",
|
||||||
"",
|
"tags": [
|
||||||
"root"
|
[
|
||||||
],
|
"e",
|
||||||
[
|
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
||||||
"p",
|
"",
|
||||||
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
"root"
|
||||||
]
|
],
|
||||||
],
|
[
|
||||||
"created_at": 1675240147,
|
"p",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
||||||
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
|
]
|
||||||
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
|
],
|
||||||
}
|
"created_at": 1675240147,
|
||||||
},
|
"relay_id": "r1",
|
||||||
{
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"name": "kind 3, contact list",
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"data": {
|
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
|
||||||
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
|
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
|
||||||
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
|
}
|
||||||
"created_at": 1675095502,
|
},
|
||||||
"kind": 3,
|
{
|
||||||
"tags": [
|
"name": "kind 3, contact list",
|
||||||
[
|
"data": {
|
||||||
"p",
|
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
|
||||||
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
"relay_id": "r1",
|
||||||
]
|
"publisher": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
|
||||||
],
|
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
|
||||||
"content": "",
|
"created_at": 1675095502,
|
||||||
"sig": "591cf6fd40c6fa6ed0b4ef47e22e52577f786a87aafcd293582076cb3ff75a9598f973fe93de833bb5a793bb3c756a853eab884323257207b2df7d217fabf9e9"
|
"kind": 3,
|
||||||
}
|
"tags": [
|
||||||
},
|
[
|
||||||
{
|
"p",
|
||||||
"name": "kind 3, relays",
|
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
||||||
"data": {
|
]
|
||||||
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
|
],
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"content": "",
|
||||||
"created_at": 1675175242,
|
"sig": "591cf6fd40c6fa6ed0b4ef47e22e52577f786a87aafcd293582076cb3ff75a9598f973fe93de833bb5a793bb3c756a853eab884323257207b2df7d217fabf9e9"
|
||||||
"kind": 3,
|
}
|
||||||
"tags": [
|
},
|
||||||
[
|
{
|
||||||
"p",
|
"name": "kind 3, relays",
|
||||||
"4b1b856e263836ef4e2ffc439f49b5f0f7b7c4bfc6fba79019ea5f0f648c55d5"
|
"data": {
|
||||||
],
|
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
|
||||||
[
|
"relay_id": "r1",
|
||||||
"p",
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"ba6dbec940142c806e5eebe02863968d2037ef50af33fd43b82309165eed1e2a"
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
],
|
"created_at": 1675175242,
|
||||||
[
|
"kind": 3,
|
||||||
"p",
|
"tags": [
|
||||||
"ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491"
|
[
|
||||||
],
|
"p",
|
||||||
[
|
"4b1b856e263836ef4e2ffc439f49b5f0f7b7c4bfc6fba79019ea5f0f648c55d5"
|
||||||
"p",
|
],
|
||||||
"69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718"
|
[
|
||||||
]
|
"p",
|
||||||
],
|
"ba6dbec940142c806e5eebe02863968d2037ef50af33fd43b82309165eed1e2a"
|
||||||
"content": "{\"wss://lnbits.link/nostrrelay/client\":{\"read\":true,\"write\":true}}",
|
],
|
||||||
"sig": "279940c52322467abcfcc10a9123f6e25542a40bc7751fef4b4941de1d5382f2bee7e0fc48a744efc4c227609d619009a0ab4557b36b35ec6df8f71e2e384b3a"
|
[
|
||||||
}
|
"p",
|
||||||
},
|
"ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491"
|
||||||
{
|
],
|
||||||
"name": "kind 4, direct message",
|
[
|
||||||
"data": {
|
"p",
|
||||||
"kind": 4,
|
"69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718"
|
||||||
"content": "gw8BFFM6anxgv77elHM5RQ==?iv=w1Qq4gPS3EZ4Csn1NfEgXg==",
|
]
|
||||||
"tags": [
|
],
|
||||||
[
|
"content": "{\"wss://lnbits.link/nostrrelay/client\":{\"read\":true,\"write\":true}}",
|
||||||
"p",
|
"sig": "279940c52322467abcfcc10a9123f6e25542a40bc7751fef4b4941de1d5382f2bee7e0fc48a744efc4c227609d619009a0ab4557b36b35ec6df8f71e2e384b3a"
|
||||||
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
}
|
||||||
]
|
},
|
||||||
],
|
{
|
||||||
"created_at": 1675240247,
|
"name": "kind 4, direct message",
|
||||||
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
"data": {
|
||||||
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
|
"kind": 4,
|
||||||
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
|
"content": "gw8BFFM6anxgv77elHM5RQ==?iv=w1Qq4gPS3EZ4Csn1NfEgXg==",
|
||||||
}
|
"tags": [
|
||||||
},
|
[
|
||||||
{
|
"p",
|
||||||
"name": "kind 5, delete message",
|
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
||||||
"data": {
|
]
|
||||||
"kind": 5,
|
],
|
||||||
"content": "deleted",
|
"created_at": 1675240247,
|
||||||
"tags": [
|
"relay_id": "r1",
|
||||||
[
|
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"e",
|
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
|
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
|
||||||
]
|
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
|
||||||
],
|
}
|
||||||
"created_at": 1675241034,
|
},
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
{
|
||||||
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
|
"name": "kind 5, delete message",
|
||||||
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
|
"data": {
|
||||||
}
|
"kind": 5,
|
||||||
},
|
"content": "deleted",
|
||||||
{
|
"tags": [
|
||||||
"name": "kind 6, mention (?)",
|
[
|
||||||
"data": {
|
"e",
|
||||||
"kind": 6,
|
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
|
||||||
"tags": [
|
]
|
||||||
[
|
],
|
||||||
"e",
|
"created_at": 1675241034,
|
||||||
"201eaebc2a3176eefa488558749a7978b5189794550c58aff885c2d362917bda",
|
"relay_id": "r1",
|
||||||
"",
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"mention"
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
],
|
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
|
||||||
[
|
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
|
||||||
"p",
|
}
|
||||||
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
},
|
||||||
]
|
{
|
||||||
],
|
"name": "kind 6, mention (?)",
|
||||||
"content": "#[0]",
|
"data": {
|
||||||
"created_at": 1675240471,
|
"kind": 6,
|
||||||
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
"tags": [
|
||||||
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
|
[
|
||||||
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
|
"e",
|
||||||
}
|
"201eaebc2a3176eefa488558749a7978b5189794550c58aff885c2d362917bda",
|
||||||
},
|
"",
|
||||||
{
|
"mention"
|
||||||
"name": "kind 7, reaction",
|
],
|
||||||
"data": {
|
[
|
||||||
"kind": 7,
|
"p",
|
||||||
"content": "+",
|
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
||||||
"tags": [
|
]
|
||||||
[
|
],
|
||||||
"e",
|
"content": "#[0]",
|
||||||
"8dacb8a9326d1b8e055386ba7f1ddf9df1cc0dd90ffe3d15802955227c311c14"
|
"created_at": 1675240471,
|
||||||
],
|
"relay_id": "r1",
|
||||||
[
|
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"p",
|
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
|
||||||
]
|
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
|
||||||
],
|
}
|
||||||
"created_at": 1675240377,
|
},
|
||||||
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
{
|
||||||
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
|
"name": "kind 7, reaction",
|
||||||
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
|
"data": {
|
||||||
}
|
"kind": 7,
|
||||||
},
|
"content": "+",
|
||||||
{
|
"tags": [
|
||||||
"name": "kind 30,000, replaceable events, 'd' tag",
|
[
|
||||||
"data": {
|
"e",
|
||||||
"kind": 30000,
|
"8dacb8a9326d1b8e055386ba7f1ddf9df1cc0dd90ffe3d15802955227c311c14"
|
||||||
"tags": [
|
],
|
||||||
[
|
[
|
||||||
"d",
|
"p",
|
||||||
"chats/null/lastOpened"
|
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"content": "1675242945",
|
"created_at": 1675240377,
|
||||||
"created_at": 1675242945,
|
"relay_id": "r1",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
|
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
|
||||||
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
|
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
|
||||||
}
|
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
|
||||||
}
|
}
|
||||||
],
|
},
|
||||||
"invalid": [
|
{
|
||||||
{
|
"name": "kind 30,000, replaceable events, 'd' tag",
|
||||||
"name": "invalid event id",
|
"data": {
|
||||||
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
|
"kind": 30000,
|
||||||
"data": {
|
"tags": [["d", "chats/null/lastOpened"]],
|
||||||
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
|
"content": "1675242945",
|
||||||
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
"created_at": 1675242945,
|
||||||
"created_at": 1675242172,
|
"relay_id": "r1",
|
||||||
"kind": 0,
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"tags": [],
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
"content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}",
|
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
|
||||||
"sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f"
|
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{
|
],
|
||||||
"name": "invalid signature",
|
"invalid": [
|
||||||
"exception": "Invalid signature: 'b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa' for event '3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96'",
|
{
|
||||||
"data": {
|
"name": "invalid event id",
|
||||||
"kind": 1,
|
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
|
||||||
"content": "i126",
|
"data": {
|
||||||
"tags": [],
|
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
|
||||||
"created_at": 1675239988,
|
"relay_id": "r1",
|
||||||
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
|
||||||
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
"created_at": 1675242172,
|
||||||
}
|
"kind": 0,
|
||||||
}
|
"tags": [],
|
||||||
]
|
"content": "{\"name\":\"chrome-snort\",\"display_name\":\"the ugly\",\"about\":\"meeeee\",\"website\":\"lnbits.com\",\"lud16\":\"nostr@lnbits.com\"}",
|
||||||
}
|
"sig": "2e3da396e6eb32962b31d490cefe422c02c5fa8a48a60f3ece27e1dcea374978b4a78b110e78dcff3859a6036287aceba1801df8877fc15266996d41235b7c4f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid signature",
|
||||||
|
"exception": "Invalid signature: 'b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa' for event '3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96'",
|
||||||
|
"data": {
|
||||||
|
"kind": 1,
|
||||||
|
"content": "i126",
|
||||||
|
"tags": [],
|
||||||
|
"created_at": 1675239988,
|
||||||
|
"relay_id": "r1",
|
||||||
|
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
|
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
|
||||||
|
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
|
||||||
|
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
FIXTURES_PATH = "tests/extensions/nostrrelay/fixture"
|
FIXTURES_PATH = "./tests/fixture"
|
||||||
|
|
||||||
|
|
||||||
def get_fixtures(file):
|
def get_fixtures(file):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,33 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from copy import deepcopy
|
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.extensions.nostrrelay.client_manager import (
|
from ..relay.client_connection import (
|
||||||
NostrClientConnection,
|
NostrClientConnection,
|
||||||
|
)
|
||||||
|
from ..relay.client_manager import (
|
||||||
NostrClientManager,
|
NostrClientManager,
|
||||||
)
|
)
|
||||||
|
from ..relay.relay import RelaySpec
|
||||||
from .helpers import get_fixtures
|
from .helpers import get_fixtures
|
||||||
|
|
||||||
fixtures = get_fixtures("clients")
|
fixtures = get_fixtures("clients")
|
||||||
alice = fixtures["alice"]
|
alice = fixtures["alice"]
|
||||||
bob = fixtures["bob"]
|
bob = fixtures["bob"]
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
@ -36,10 +40,13 @@ 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: str | None = None) -> None:
|
||||||
|
logger.info(f"{code}: {reason}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_alice_and_bob():
|
async def test_alice_and_bob():
|
||||||
ws_alice, ws_bob = init_clients()
|
ws_alice, ws_bob = await init_clients()
|
||||||
|
|
||||||
await alice_wires_meta_and_post01(ws_alice)
|
await alice_wires_meta_and_post01(ws_alice)
|
||||||
|
|
||||||
|
|
@ -62,18 +69,25 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
def init_clients():
|
tasks = []
|
||||||
|
|
||||||
|
|
||||||
|
async def init_clients():
|
||||||
client_manager = NostrClientManager()
|
client_manager = NostrClientManager()
|
||||||
|
await client_manager.enable_relay(RELAY_ID, RelaySpec())
|
||||||
|
|
||||||
ws_alice = MockWebSocket()
|
ws_alice = MockWebSocket()
|
||||||
client_alice = NostrClientConnection(websocket=ws_alice)
|
client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice)
|
||||||
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(websocket=ws_bob)
|
client_bob = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_bob)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -88,16 +102,13 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket):
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
len(ws_alice.sent_messages) == 4
|
len(ws_alice.sent_messages) == 4
|
||||||
), "Alice: Expected 3 confirmations to be sent"
|
), "Alice: Expected 4 confirmations to be sent"
|
||||||
assert ws_alice.sent_messages[0] == dumps(
|
assert ws_alice.sent_messages[0] == dumps(
|
||||||
alice["meta_response"]
|
alice["meta_response"]
|
||||||
), "Alice: Wrong confirmation for meta"
|
), "Alice: Wrong confirmation for meta"
|
||||||
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"
|
||||||
|
|
@ -142,10 +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,50 +1,37 @@
|
||||||
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 create_event, get_event, get_events
|
from ..crud import (
|
||||||
from lnbits.extensions.nostrrelay.models import NostrEvent, NostrFilter
|
create_event,
|
||||||
|
get_event,
|
||||||
from .helpers import get_fixtures
|
get_events,
|
||||||
|
)
|
||||||
|
from ..relay.event import NostrEvent
|
||||||
|
from ..relay.filter import NostrFilter
|
||||||
|
from .conftest import EventFixture
|
||||||
|
|
||||||
RELAY_ID = "r1"
|
RELAY_ID = "r1"
|
||||||
|
|
||||||
class EventFixture(BaseModel):
|
|
||||||
name: str
|
|
||||||
exception: Optional[str]
|
|
||||||
data: NostrEvent
|
|
||||||
|
|
||||||
|
def test_valid_event_id_and_signature(valid_events: list[EventFixture]):
|
||||||
@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()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Invalid 'id' ot 'signature' for fixture: '{f.name}'")
|
logger.error(f"Invalid 'id' of 'signature' for fixture: '{f.name}'")
|
||||||
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"
|
||||||
|
|
@ -52,14 +39,12 @@ 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)
|
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)
|
||||||
await filter_by_id(all_events, f.data, f.name)
|
await filter_by_id(all_events, f.data, f.name)
|
||||||
|
|
||||||
|
|
||||||
await filter_by_author(all_events, author)
|
await filter_by_author(all_events, author)
|
||||||
|
|
||||||
await filter_by_tag_p(all_events, author)
|
await filter_by_tag_p(all_events, author)
|
||||||
|
|
@ -70,76 +55,99 @@ async def test_valid_event_crud(valid_events: List[EventFixture]):
|
||||||
|
|
||||||
await filter_by_tag_e_p_and_author(all_events, author, event_id, reply_event_id)
|
await filter_by_tag_e_p_and_author(all_events, author, event_id, reply_event_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_by_id(data: NostrEvent, test_name: str):
|
async def get_by_id(data: NostrEvent, test_name: str):
|
||||||
event = await get_event(RELAY_ID, data.id)
|
event = await get_event(RELAY_ID, data.id)
|
||||||
assert event, f"Failed to restore event (id='{data.id}')"
|
assert event, f"Failed to restore event (id='{data.id}')"
|
||||||
assert event.json() != json.dumps(data.json()), f"Restored event is different for fixture '{test_name}'"
|
assert event.json() != json.dumps(
|
||||||
|
data.json()
|
||||||
|
), f"Restored event is different for fixture '{test_name}'"
|
||||||
|
|
||||||
async def filter_by_id(all_events: List[NostrEvent], data: NostrEvent, test_name: str):
|
|
||||||
filter = NostrFilter(ids=[data.id])
|
|
||||||
|
|
||||||
events = await get_events(RELAY_ID, filter)
|
async def filter_by_id(all_events: list[NostrEvent], data: NostrEvent, test_name: str):
|
||||||
|
nostr_filter = NostrFilter(ids=[data.id])
|
||||||
|
|
||||||
|
events = await get_events(RELAY_ID, 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(data.json()), f"Queried event is different for fixture '{test_name}'"
|
assert events[0].json() != json.dumps(
|
||||||
|
data.json()
|
||||||
|
), f"Queried event is different for fixture '{test_name}'"
|
||||||
|
|
||||||
filtered_events = [e for e in all_events if filter.matches(e)]
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
assert len(filtered_events) == 1, f"Expected one filter event '{test_name}'"
|
assert len(filtered_events) == 1, f"Expected one filter event '{test_name}'"
|
||||||
assert filtered_events[0].json() != json.dumps(data.json()), f"Filtered event is different for fixture '{test_name}'"
|
assert filtered_events[0].json() != json.dumps(
|
||||||
|
data.json()
|
||||||
|
), f"Filtered event is different for fixture '{test_name}'"
|
||||||
|
|
||||||
async def filter_by_author(all_events: List[NostrEvent], author):
|
|
||||||
filter = NostrFilter(authors=[author])
|
|
||||||
events_by_author = await get_events(RELAY_ID, filter)
|
|
||||||
assert len(events_by_author) == 5, f"Failed to query by authors"
|
|
||||||
|
|
||||||
filtered_events = [e for e in all_events if filter.matches(e)]
|
async def filter_by_author(all_events: list[NostrEvent], author):
|
||||||
assert len(filtered_events) == 5, f"Failed to filter by authors"
|
nostr_filter = NostrFilter(authors=[author])
|
||||||
|
events_by_author = await get_events(RELAY_ID, nostr_filter)
|
||||||
|
assert len(events_by_author) == 5, "Failed to query by authors"
|
||||||
|
|
||||||
async def filter_by_tag_p(all_events: List[NostrEvent], author):
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
|
assert len(filtered_events) == 5, "Failed to filter by authors"
|
||||||
|
|
||||||
|
|
||||||
|
async def filter_by_tag_p(all_events: list[NostrEvent], author):
|
||||||
# todo: check why constructor does not work for fields with aliases (#e, #p)
|
# 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(all_events: List[NostrEvent], author, event_id, reply_event_id):
|
|
||||||
filter = NostrFilter()
|
|
||||||
filter.p.append(author)
|
|
||||||
filter.e.append(event_id)
|
|
||||||
|
|
||||||
events_related_to_event = await get_events(RELAY_ID, filter)
|
|
||||||
assert len(events_related_to_event) == 1, f"Failed to quert by tags 'e' & 'p'"
|
|
||||||
assert events_related_to_event[0].id == reply_event_id, f"Failed to query the right event by tags 'e' & 'p'"
|
|
||||||
|
|
||||||
filtered_events = [e for e in all_events if filter.matches(e)]
|
|
||||||
assert len(filtered_events) == 1, f"Failed to filter by tags 'e' & 'p'"
|
|
||||||
assert filtered_events[0].id == reply_event_id, f"Failed to find the right event by tags 'e' & 'p'"
|
|
||||||
|
|
||||||
async def filter_by_tag_e_p_and_author(all_events: List[NostrEvent], author, event_id, reply_event_id):
|
|
||||||
filter = NostrFilter(authors=[author])
|
|
||||||
filter.p.append(author)
|
|
||||||
filter.e.append(event_id)
|
|
||||||
events_related_to_event = await get_events(RELAY_ID, filter)
|
|
||||||
assert len(events_related_to_event) == 1, f"Failed to query by 'author' and tags 'e' & 'p'"
|
|
||||||
assert events_related_to_event[0].id == reply_event_id, f"Failed to query the right event by 'author' and tags 'e' & 'p'"
|
|
||||||
|
|
||||||
filtered_events = [e for e in all_events if filter.matches(e)]
|
|
||||||
assert len(filtered_events) == 1, f"Failed to filter by 'author' and tags 'e' & 'p'"
|
|
||||||
assert filtered_events[0].id == reply_event_id, f"Failed to filter the right event by 'author' and tags 'e' & 'p'"
|
|
||||||
|
|
||||||
|
|
||||||
|
async def filter_by_tag_e_and_p(
|
||||||
|
all_events: list[NostrEvent], author, event_id, reply_event_id
|
||||||
|
):
|
||||||
|
nostr_filter = NostrFilter()
|
||||||
|
nostr_filter.p.append(author)
|
||||||
|
nostr_filter.e.append(event_id)
|
||||||
|
|
||||||
|
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
|
||||||
|
assert len(events_related_to_event) == 1, "Failed to quert by tags 'e' & 'p'"
|
||||||
|
assert (
|
||||||
|
events_related_to_event[0].id == reply_event_id
|
||||||
|
), "Failed to query the right event by tags 'e' & 'p'"
|
||||||
|
|
||||||
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
|
assert len(filtered_events) == 1, "Failed to filter by tags 'e' & 'p'"
|
||||||
|
assert (
|
||||||
|
filtered_events[0].id == reply_event_id
|
||||||
|
), "Failed to find the right event by tags 'e' & 'p'"
|
||||||
|
|
||||||
|
|
||||||
|
async def filter_by_tag_e_p_and_author(
|
||||||
|
all_events: list[NostrEvent], author, event_id, reply_event_id
|
||||||
|
):
|
||||||
|
nostr_filter = NostrFilter(authors=[author])
|
||||||
|
nostr_filter.p.append(author)
|
||||||
|
nostr_filter.e.append(event_id)
|
||||||
|
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
|
||||||
|
assert (
|
||||||
|
len(events_related_to_event) == 1
|
||||||
|
), "Failed to query by 'author' and tags 'e' & 'p'"
|
||||||
|
assert (
|
||||||
|
events_related_to_event[0].id == reply_event_id
|
||||||
|
), "Failed to query the right event by 'author' and tags 'e' & 'p'"
|
||||||
|
|
||||||
|
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
|
||||||
|
assert len(filtered_events) == 1, "Failed to filter by 'author' and tags 'e' & 'p'"
|
||||||
|
assert (
|
||||||
|
filtered_events[0].id == reply_event_id
|
||||||
|
), "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()
|
||||||
132
tests/test_nip17.py
Normal file
132
tests/test_nip17.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""
|
||||||
|
Unit tests for NIP-17 (Private Direct Messages) handling.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- kind 1059 (gift wrap) and kind 13 (seal) classification on NostrEvent
|
||||||
|
- the AUTH-gated private-recipient delivery rule in NostrClientConnection
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ..relay.client_connection import NostrClientConnection
|
||||||
|
from ..relay.event import NostrEvent
|
||||||
|
from ..relay.relay import RelaySpec
|
||||||
|
|
||||||
|
RELAY_ID = "relay_nip17"
|
||||||
|
RECIPIENT = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
OTHER = "2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
EPHEMERAL = "3333333333333333333333333333333333333333333333333333333333333333"
|
||||||
|
SIG = "0" * 128
|
||||||
|
|
||||||
|
|
||||||
|
def _gift_wrap_for(recipient: str) -> NostrEvent:
|
||||||
|
"""Build a kind 1059 event addressed to recipient. Skips signature validity."""
|
||||||
|
return NostrEvent(
|
||||||
|
id="0" * 64,
|
||||||
|
relay_id=RELAY_ID,
|
||||||
|
publisher=EPHEMERAL,
|
||||||
|
pubkey=EPHEMERAL,
|
||||||
|
created_at=0,
|
||||||
|
kind=1059,
|
||||||
|
tags=[["p", recipient]],
|
||||||
|
content="ciphertext",
|
||||||
|
sig=SIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kind_classification_helpers():
|
||||||
|
seal = NostrEvent(
|
||||||
|
id="0" * 64,
|
||||||
|
relay_id=RELAY_ID,
|
||||||
|
publisher=OTHER,
|
||||||
|
pubkey=OTHER,
|
||||||
|
created_at=0,
|
||||||
|
kind=13,
|
||||||
|
tags=[],
|
||||||
|
content="",
|
||||||
|
sig=SIG,
|
||||||
|
)
|
||||||
|
wrap = _gift_wrap_for(RECIPIENT)
|
||||||
|
|
||||||
|
assert seal.is_seal
|
||||||
|
assert not seal.is_gift_wrap
|
||||||
|
assert not seal.is_private_message # seals carry no recipient metadata
|
||||||
|
|
||||||
|
assert wrap.is_gift_wrap
|
||||||
|
assert not wrap.is_seal
|
||||||
|
assert wrap.is_private_message
|
||||||
|
assert not wrap.is_ephemeral_event # nostrmarket relies on storage
|
||||||
|
|
||||||
|
|
||||||
|
def _make_connection(relay_spec: RelaySpec) -> NostrClientConnection:
|
||||||
|
conn = NostrClientConnection(relay_id=RELAY_ID, websocket=MagicMock())
|
||||||
|
conn.get_client_config = lambda: relay_spec
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"force_auth,auth_pubkey,event_recipient,expected_filtered",
|
||||||
|
[
|
||||||
|
# AUTH not required for 1059 -> never filtered (matches NIP-04 default)
|
||||||
|
(False, None, RECIPIENT, False),
|
||||||
|
(False, RECIPIENT, RECIPIENT, False),
|
||||||
|
(False, OTHER, RECIPIENT, False),
|
||||||
|
# AUTH required for 1059 -> only the recipient gets it
|
||||||
|
(True, None, RECIPIENT, True),
|
||||||
|
(True, RECIPIENT, RECIPIENT, False),
|
||||||
|
(True, OTHER, RECIPIENT, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_gift_wrap_auth_gated_delivery(
|
||||||
|
force_auth, auth_pubkey, event_recipient, expected_filtered
|
||||||
|
):
|
||||||
|
spec = RelaySpec(forcedAuthEvents=[1059] if force_auth else [])
|
||||||
|
conn = _make_connection(spec)
|
||||||
|
conn.auth_pubkey = auth_pubkey
|
||||||
|
|
||||||
|
wrap = _gift_wrap_for(event_recipient)
|
||||||
|
assert conn._is_private_event_for_other(wrap) is expected_filtered
|
||||||
|
|
||||||
|
|
||||||
|
def test_kind_4_dm_still_gated_under_auth():
|
||||||
|
"""Regression: the NIP-04 gating behavior must remain identical."""
|
||||||
|
spec = RelaySpec(forcedAuthEvents=[4])
|
||||||
|
conn = _make_connection(spec)
|
||||||
|
conn.auth_pubkey = OTHER
|
||||||
|
|
||||||
|
dm = NostrEvent(
|
||||||
|
id="0" * 64,
|
||||||
|
relay_id=RELAY_ID,
|
||||||
|
publisher=RECIPIENT,
|
||||||
|
pubkey=RECIPIENT,
|
||||||
|
created_at=0,
|
||||||
|
kind=4,
|
||||||
|
tags=[["p", RECIPIENT]],
|
||||||
|
content="ciphertext",
|
||||||
|
sig=SIG,
|
||||||
|
)
|
||||||
|
assert conn._is_private_event_for_other(dm) is True
|
||||||
|
|
||||||
|
conn.auth_pubkey = RECIPIENT
|
||||||
|
assert conn._is_private_event_for_other(dm) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_private_kinds_never_filtered():
|
||||||
|
spec = RelaySpec(forcedAuthEvents=[1059, 4])
|
||||||
|
conn = _make_connection(spec)
|
||||||
|
conn.auth_pubkey = OTHER
|
||||||
|
|
||||||
|
note = NostrEvent(
|
||||||
|
id="0" * 64,
|
||||||
|
relay_id=RELAY_ID,
|
||||||
|
publisher=RECIPIENT,
|
||||||
|
pubkey=RECIPIENT,
|
||||||
|
created_at=0,
|
||||||
|
kind=1,
|
||||||
|
tags=[["p", RECIPIENT]],
|
||||||
|
content="hello",
|
||||||
|
sig=SIG,
|
||||||
|
)
|
||||||
|
assert conn._is_private_event_for_other(note) is False
|
||||||
29
toc.md
Normal file
29
toc.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Terms and Conditions for LNbits Extension
|
||||||
|
|
||||||
|
## 1. Acceptance of Terms
|
||||||
|
|
||||||
|
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
|
||||||
|
|
||||||
|
## 2. License
|
||||||
|
|
||||||
|
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
|
||||||
|
|
||||||
|
## 3. No Warranty
|
||||||
|
|
||||||
|
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
|
||||||
|
|
||||||
|
## 4. Limitation of Liability
|
||||||
|
|
||||||
|
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
|
||||||
|
|
||||||
|
## 5. Modification of Terms
|
||||||
|
|
||||||
|
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
|
||||||
|
|
||||||
|
## 6. General Provisions
|
||||||
|
|
||||||
|
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
|
||||||
|
|
||||||
|
## 7. Contact Information
|
||||||
|
|
||||||
|
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
||||||
42
views.py
42
views.py
|
|
@ -1,29 +1,41 @@
|
||||||
from fastapi import Depends, Request
|
from http import HTTPStatus
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
from lnbits.core.models import User
|
from lnbits.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 .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("/public")
|
@nostrrelay_generic_router.get("/{relay_id}")
|
||||||
async def nostrrelay(request: Request, nostrrelay_id):
|
async def nostrrelay(request: Request, relay_id: str):
|
||||||
|
relay_public_data = await get_public_relay(relay_id)
|
||||||
|
|
||||||
|
if not relay_public_data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Cannot find relay",
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.headers.get("accept") == "application/nostr+json":
|
||||||
|
return relay_info_response(relay_public_data)
|
||||||
|
|
||||||
return nostrrelay_renderer().TemplateResponse(
|
return nostrrelay_renderer().TemplateResponse(
|
||||||
"nostrrelay/public.html",
|
"nostrrelay/public.html", {"request": request, "relay": relay_public_data}
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
# "nostrrelay": relay,
|
|
||||||
"web_manifest": f"/nostrrelay/manifest/{nostrrelay_id}.webmanifest",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
296
views_api.py
296
views_api.py
|
|
@ -1,38 +1,46 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, WebSocket
|
|
||||||
from fastapi.exceptions import HTTPException
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from loguru import logger
|
|
||||||
from pydantic.types import UUID4
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.models import WalletTypeInfo
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.decorators import (
|
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
|
from .client_manager import client_manager
|
||||||
from .client_manager import NostrClientConnection, NostrClientManager
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
|
create_account,
|
||||||
create_relay,
|
create_relay,
|
||||||
|
delete_account,
|
||||||
|
delete_all_events,
|
||||||
delete_relay,
|
delete_relay,
|
||||||
get_public_relay,
|
get_account,
|
||||||
|
get_accounts,
|
||||||
get_relay,
|
get_relay,
|
||||||
|
get_relay_by_id,
|
||||||
get_relays,
|
get_relays,
|
||||||
|
update_account,
|
||||||
update_relay,
|
update_relay,
|
||||||
)
|
)
|
||||||
from .models import NostrRelay
|
from .helpers import extract_domain, normalize_public_key, relay_info_response
|
||||||
|
from .models import BuyOrder, NostrAccount, NostrPartialAccount
|
||||||
|
from .relay.client_manager import NostrClientConnection
|
||||||
|
from .relay.relay import NostrRelay
|
||||||
|
|
||||||
client_manager = NostrClientManager()
|
nostrrelay_api_router = APIRouter()
|
||||||
|
|
||||||
@nostrrelay_ext.websocket("/{relay_id}")
|
|
||||||
|
@nostrrelay_api_router.websocket("/{relay_id}")
|
||||||
|
@nostrrelay_api_router.websocket("/{relay_id}/")
|
||||||
async def websocket_endpoint(relay_id: str, websocket: WebSocket):
|
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)
|
||||||
if not (await client_manager.add_client(client)):
|
client_accepted = await client_manager.add_client(client)
|
||||||
|
if not client_accepted:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -42,93 +50,95 @@ async def websocket_endpoint(relay_id: str, websocket: WebSocket):
|
||||||
client_manager.remove_client(client)
|
client_manager.remove_client(client)
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.post("/api/v1/relay")
|
||||||
@nostrrelay_ext.get("/{relay_id}", status_code=HTTPStatus.OK)
|
async def api_create_relay(
|
||||||
async def api_nostrrelay_info(relay_id: str):
|
data: NostrRelay,
|
||||||
relay = await get_public_relay(relay_id)
|
request: Request,
|
||||||
if not relay:
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
raise HTTPException(
|
) -> NostrRelay:
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
data.user_id = key_info.wallet.user
|
||||||
detail="Relay not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(content=relay, headers={
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@nostrrelay_ext.post("/api/v1/relay")
|
|
||||||
async def api_create_relay(data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay:
|
|
||||||
if len(data.id):
|
if len(data.id):
|
||||||
await check_admin(UUID4(wallet.wallet.user))
|
user = await get_user(data.user_id)
|
||||||
|
assert user, "User not found."
|
||||||
|
assert user.admin, "Only admin users can set the relay ID"
|
||||||
else:
|
else:
|
||||||
data.id = urlsafe_short_hash()[:8]
|
data.id = urlsafe_short_hash()[:8]
|
||||||
|
|
||||||
try:
|
data.meta.domain = extract_domain(str(request.url))
|
||||||
relay = await create_relay(wallet.wallet.user, data)
|
relay = await create_relay(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.put("/api/v1/relay/{relay_id}")
|
@nostrrelay_api_router.patch("/api/v1/relay/{relay_id}")
|
||||||
async def api_update_relay(relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)) -> NostrRelay:
|
async def api_update_relay(
|
||||||
|
relay_id: str, data: NostrRelay, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
) -> NostrRelay:
|
||||||
if relay_id != data.id:
|
if relay_id != data.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
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)
|
|
||||||
await client_manager.toggle_relay(relay_id, updated_relay.active)
|
|
||||||
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.get("/api/v1/relay")
|
# activate & deactivate have their own endpoint
|
||||||
async def api_get_relays(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> List[NostrRelay]:
|
updated_relay.active = relay.active
|
||||||
try:
|
|
||||||
return await get_relays(wallet.wallet.user)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot fetch relays",
|
|
||||||
)
|
|
||||||
|
|
||||||
@nostrrelay_ext.get("/api/v1/relay/{relay_id}")
|
if updated_relay.active:
|
||||||
async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)) -> Optional[NostrRelay]:
|
await client_manager.enable_relay(relay_id, updated_relay.meta)
|
||||||
try:
|
else:
|
||||||
relay = await get_relay(wallet.wallet.user, relay_id)
|
await client_manager.disable_relay(relay_id)
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
return updated_relay
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.put("/api/v1/relay/{relay_id}")
|
||||||
|
async def api_toggle_relay(
|
||||||
|
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
) -> NostrRelay:
|
||||||
|
relay = await get_relay(wallet.wallet.user, relay_id)
|
||||||
|
if not relay:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail="Cannot fetch 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_api_router.get("/api/v1/relay")
|
||||||
|
async def api_get_relays(
|
||||||
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
|
) -> list[NostrRelay]:
|
||||||
|
return await get_relays(wallet.wallet.user)
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.get("/api/v1/relay-info")
|
||||||
|
async def api_get_relay_info() -> JSONResponse:
|
||||||
|
return relay_info_response(NostrRelay.info())
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.get("/api/v1/relay/{relay_id}")
|
||||||
|
async def api_get_relay(
|
||||||
|
relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
|
) -> NostrRelay | None:
|
||||||
|
relay = await get_relay(wallet.wallet.user, relay_id)
|
||||||
if not relay:
|
if not relay:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
|
@ -136,13 +146,127 @@ async def api_get_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_
|
||||||
)
|
)
|
||||||
return relay
|
return relay
|
||||||
|
|
||||||
@nostrrelay_ext.delete("/api/v1/relay/{relay_id}")
|
|
||||||
async def api_delete_relay(relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
@nostrrelay_api_router.put("/api/v1/account", dependencies=[Depends(require_admin_key)])
|
||||||
|
async def api_create_or_update_account(
|
||||||
|
data: NostrPartialAccount,
|
||||||
|
) -> NostrAccount:
|
||||||
|
data.pubkey = normalize_public_key(data.pubkey)
|
||||||
|
account = await get_account(data.relay_id, data.pubkey)
|
||||||
|
if not account:
|
||||||
|
account = NostrAccount(
|
||||||
|
pubkey=data.pubkey,
|
||||||
|
relay_id=data.relay_id,
|
||||||
|
blocked=data.blocked or False,
|
||||||
|
allowed=data.allowed or False,
|
||||||
|
)
|
||||||
|
return await create_account(account)
|
||||||
|
|
||||||
|
if data.blocked is not None:
|
||||||
|
account.blocked = data.blocked
|
||||||
|
if data.allowed is not None:
|
||||||
|
account.allowed = data.allowed
|
||||||
|
|
||||||
|
return await update_account(account)
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.delete(
|
||||||
|
"/api/v1/account/{relay_id}/{pubkey}", dependencies=[Depends(require_admin_key)]
|
||||||
|
)
|
||||||
|
async def api_delete_account(
|
||||||
|
relay_id: str,
|
||||||
|
pubkey: str,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
|
pubkey = normalize_public_key(pubkey)
|
||||||
|
except ValueError as ex:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Invalid pubkey: {ex!s}",
|
||||||
|
) from ex
|
||||||
|
return await delete_account(relay_id, pubkey)
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.get("/api/v1/account")
|
||||||
|
async def api_get_accounts(
|
||||||
|
relay_id: str,
|
||||||
|
allowed: bool = False,
|
||||||
|
blocked: bool = True,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
|
) -> list[NostrAccount]:
|
||||||
|
# make sure the user has access to the relay
|
||||||
|
relay = await get_relay(wallet.wallet.user, relay_id)
|
||||||
|
if not relay:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Relay not found",
|
||||||
|
)
|
||||||
|
accounts = await get_accounts(relay.id, allowed, blocked)
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
@nostrrelay_api_router.delete("/api/v1/relay/{relay_id}")
|
||||||
|
async def api_delete_relay(
|
||||||
|
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await client_manager.disable_relay(relay_id)
|
||||||
await delete_relay(wallet.wallet.user, relay_id)
|
await delete_relay(wallet.wallet.user, relay_id)
|
||||||
|
await delete_all_events(relay_id)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
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_api_router.put("/api/v1/pay")
|
||||||
|
async def api_pay_to_join(data: BuyOrder):
|
||||||
|
pubkey = normalize_public_key(data.pubkey)
|
||||||
|
relay = await get_relay_by_id(data.relay_id)
|
||||||
|
if not relay:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Relay not found",
|
||||||
)
|
)
|
||||||
|
amount = 0
|
||||||
|
storage_to_buy = 0
|
||||||
|
if data.action == "join":
|
||||||
|
if relay.is_free_to_join:
|
||||||
|
raise ValueError("Relay is free to join")
|
||||||
|
amount = int(relay.meta.cost_to_join)
|
||||||
|
elif data.action == "storage":
|
||||||
|
if relay.meta.storage_cost_value == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Relay storage cost is zero. Cannot buy!",
|
||||||
|
)
|
||||||
|
if data.units_to_buy == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Must specify how much storage to buy!",
|
||||||
|
)
|
||||||
|
storage_to_buy = data.units_to_buy * relay.meta.storage_cost_value * 1024
|
||||||
|
if relay.meta.storage_cost_unit == "MB":
|
||||||
|
storage_to_buy *= 1024
|
||||||
|
amount = data.units_to_buy * relay.meta.storage_cost_value
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Unknown action: '{data.action}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = await create_invoice(
|
||||||
|
wallet_id=relay.meta.wallet,
|
||||||
|
amount=amount,
|
||||||
|
memo=f"Pubkey '{data.pubkey}' wants to join {relay.id}",
|
||||||
|
extra={
|
||||||
|
"tag": "nostrrely",
|
||||||
|
"action": data.action,
|
||||||
|
"relay_id": relay.id,
|
||||||
|
"pubkey": pubkey,
|
||||||
|
"storage_to_buy": storage_to_buy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"invoice": payment.bolt11}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue