Compare commits

...

206 commits

Author SHA1 Message Date
4811fcf352 feat(nip17): support gift-wrapped private direct messages
Some checks failed
ci.yml / feat(nip17): support gift-wrapped private direct messages (pull_request) Failing after 0s
ci.yml / feat(nip17): support gift-wrapped private direct messages (push) Failing after 0s
Generalize the AUTH-gated, recipient-only delivery rule from NIP-04 to
also cover NIP-17 kind 1059 gift wraps. When the relay is configured to
require AUTH for kind 1059, only the AUTH'd recipient named in the
event's `p` tag receives it; otherwise gift wraps broadcast like any
regular event.

- relay/event.py: add `is_seal`, `is_gift_wrap`, `is_private_message`
  helpers (kinds 13, 1059)
- relay/client_connection.py: rename `_is_direct_message_for_other` ->
  `_is_private_event_for_other`; key off `is_private_message` so the
  same gating applies to kinds 4 and 1059
- relay/relay.py: advertise NIPs 17, 44, 59 in NIP-11 supported_nips
- README: document NIP-17/44/59 transport-level support
- tests/test_nip17.py: unit tests for kind classification, AUTH-gated
  1059 delivery (recipient vs non-recipient vs unauthenticated), and
  regression coverage for kind 4 gating

NIP-44 (encryption) and NIP-59 (wrap/seal) are client-side concerns;
the relay treats payloads as opaque ciphertext and stores kind 1059
like any regular event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:59:14 +02:00
b74af2628e fix(nostrrelay): populate size field for event storage accounting
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
Changes:
- relay/event.py: Add `size: int = 0` field to NostrEvent model
- relay/client_connection.py: Set `event.size = event.size_bytes` when creating events from WebSocket messages

The size field has existed in the database schema since migration m001 but was never populated, causing:
  - Incorrect storage accounting (always 0)
  - Broken storage quota enforcement
  - Failed event pruning when storage limits reached

The size field is internal relay metadata and is excluded from the nostr_dict() output, maintaining NIP-01 compliance. The size_bytes property calculates the actual byte size of the event's JSON representation.

Fixes: Database constraint violation when inserting events without the required size column value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:53:37 +01:00
8d6f482de0 Fix critical filter logic bugs preventing event propagation in Nostr relay
Some checks failed
CI / lint (push) Waiting to run
CI / tests (push) Blocked by required conditions
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
- Fix inverted logic in _can_add_filter() method that was preventing new
subscription filters from being added
  - Fix REQ message handling to properly clear existing filters before
adding new ones
  - Fix inverted condition check when validating filter addition
capacity
  - Add debug logging to track filter matching and broadcast failures

  These bugs were causing customer order events (NIP-15) to be received
by the relay but not
  forwarded to nostrclient/nostrmarket, requiring server restarts or
manual refresh to process orders.
  The fix ensures proper event propagation: Customer → Relay →
nostrclient → nostrmarket → Invoice.

  Root cause: The _can_add_filter() method returned true when filters >=
max instead of when
  filters < max, and the validation check used the wrong conditional,
effectively blocking all
  new filter subscriptions after initial connection.
2026-01-06 23:05:22 +01:00
Patrick Mulligan
5e95b309fe make format
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-11-17 00:01:21 +01:00
Patrick Mulligan
8547864254 docs: Update README with complete NIP-09 deletion support
Updated NIP-09 section to document full implementation including:
  - 'e' tags for deleting regular events by event ID
  - 'a' tags for deleting addressable events by address format (kind:pubkey:d-identifier)

  This reflects the implementation added in commits 3ba3318 and 538fe42 which brought the relay into full NIP-09 compliance.
2025-11-17 00:01:21 +01:00
Patrick Mulligan
dcc3204735 Fix NIP-09 deletion for parameterized replaceable events (NIP-33)
Fixed bug where deleting a parameterized replaceable event (e.g., kind 31922)
using an 'a' tag would incorrectly delete ALL events of that kind instead of
just the specific event with the matching d-tag.

**Root Cause:**
NostrFilter's 'd' field uses a Pydantic Field alias "#d". When creating a filter
with `NostrFilter(d=[value])`, Pydantic ignores it because the parameter name
doesn't match the alias.

**Fix:**
Changed filter creation to use the alias:
```python
NostrFilter(authors=[...], kinds=[...], **{"#d": [d_tag]})
```

**Testing:**
- Created two tasks with different d-tags
- Deleted only one task
- Verified only the specified task was marked as deleted in the database
- Confirmed the other task remained unaffected

This ensures proper NIP-09 deletion behavior for NIP-33 parameterized
replaceable events using 'a' tag format (kind:pubkey:d-identifier).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 00:01:21 +01:00
Patrick Mulligan
8bfd792548 Add NIP-09 support for parameterized replaceable events (NIP-33)
Extended NIP-09 deletion event handling to support both regular events
and parameterized replaceable events (NIP-33).

**Previous behavior:**
- Only handled 'e' tags (regular event IDs)
- Did not support 'a' tags for addressable/replaceable events

**New behavior:**
- Handles both 'e' tags (event IDs) and 'a' tags (event addresses)
- Parses 'a' tag format: kind:pubkey:d-identifier
- Validates deletion author matches event address pubkey (NIP-09 requirement)
- Creates appropriate filters for each deletion type

**Implementation:**
- Added parsing for 'a' tag event addresses
- Extract kind, pubkey, and d-tag from address format
- Build NostrFilter with authors, kinds, and d-tag parameters
- Collect all event IDs to delete from both 'e' and 'a' tags
- Mark matching events as deleted in single operation

This enables proper deletion of parameterized replaceable events like
calendar events (kind 31922-31924), long-form content (kind 30023),
and other addressable event kinds.

Implements NIP-09: https://github.com/nostr-protocol/nips/blob/master/09.md
Supports NIP-33: https://github.com/nostr-protocol/nips/blob/master/33.md
2025-11-17 00:01:21 +01:00
Vlad Stan
c4efb87b70
chore: bump version (#41)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-11-13 12:44:22 +02:00
dni ⚡
a53d2d7767
refactor: get rid of secp lib (#40) 2025-11-04 10:30:43 +02:00
dni ⚡
35584a230f
chore: add uv, linting, fixes (#39)
* chore: add uv, linting, fixes
2025-10-30 10:43:27 +01:00
PatMulligan
15079c3e58
fix(nostrrelay): use schema-qualified table name in delete_events (#38) 2025-10-27 09:54:29 +02:00
PatMulligan
22df5868de
FEAT: Implement NIP-01 Addressable Events Support (#33)
* Implement NIP-16 parameterized replaceable events

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

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

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

* Refactor event handling for addressable events

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

* Refactor tag filtering logic in NostrFilter

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

* update readme

* Fix addressable event deletion and SQL schema issues

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

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

This ensures compatibility with clients that batch multiple subscription
filters in a single REQ message, which is a valid pattern according to
NIP-01.
2025-09-10 16:35:25 +03:00
21M4TW
5a1a400f45
-Two issues were dicovered in get_config_for_all_active_relays which led (#32)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
to errors while loading relay configuration from the DB and that also
caused the loaded meta information to be invalid. Mandatory fields for
NostrRelay were not selected by the query, and a dictionary
representation of the meta object should not be returned as it causes
some members such as require_auth_filter and event_requires_auth to not
be accessible, leading to breaking exceptions.
2025-06-16 11:59:13 +03:00
Vlad Stan
3dc066fbd4
[fix] Ws api tests (#31)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-11-11 15:25:25 +02:00
dni ⚡
73054fd5ce
feat: update to v1.0.0 (#30) 2024-11-08 15:32:04 +02:00
Bitkarrot
2bdbbb274d
Update client_connection.py (#29)
omit return none, in ref to https://github.com/lnbits/nostrrelay/issues/27
2024-10-14 09:39:48 +03:00
dni ⚡
cc6752003a
feat: improve codequality and CI (#25)
* feat: improve codequality and CI
2024-08-30 13:20:23 +02:00
dni ⚡
28121184c3
fix: properly start/stop tasks (#22)
https://github.com/lnbits/nostrclient/pull/28
2024-06-26 10:29:04 +02:00
Arc
94d383baff
Merge pull request #24 from lnbits/advanceddescription
Added advanced description
2024-05-17 12:16:47 +01:00
benarc
6d4f561e4e typo 2024-05-17 12:15:11 +01:00
benarc
d448ba6fa5 Added advanced description 2024-05-15 13:21:42 +01:00
dni ⚡
349bfa49d1
chore: rename websocketUpdater (#21)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* chore: rename `websocketUpdater`
https://github.com/lnbits/lnbits/pull/2377
2024-04-12 12:11:03 +02:00
Vlad Stan
43eed0df35
fix: repository 2024-03-21 15:35:19 +02:00
Vlad Stan
94ae34158c
fix: admin check (#20)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-03-21 11:29:36 +02:00
Tiago Vasconcelos
473614f8be
allow custom path (#17)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* allow custom path

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-09-26 16:56:21 +02:00
dni ⚡
0215986a59
release workflow (#18) 2023-09-26 16:55:51 +02:00
Tiago Vasconcelos
3c45acc4d5 copy changes
more copy
clean debugging... prints! lol
clean console logs
fix get users

Only get users that explicitly have been allowed or blocked
UI
UI-2
prevent getting all accounts (false, false is default)
remove client side filtering


remove prints
2023-07-26 11:28:20 +03:00
Vlad Stan
07a07fc079 fix: do not remove README from archive 2023-04-19 14:41:14 +03:00
Vlad Stan
ccf620d476 feat: check account for REQ 2023-04-06 18:14:35 +03:00
Vlad Stan
dc50d3493d fix: is free to join if cost_to_join == 0 2023-04-06 18:14:12 +03:00
Vlad Stan
8ed361230b refactor: prepare for pay check 2023-04-06 17:33:09 +03:00
Vlad Stan
fddc8a1d22 refactor: field name 2023-04-06 17:31:49 +03:00
Vlad Stan
1a1875e177 fix: duplicate event 2023-04-06 17:31:26 +03:00
Vlad Stan
f19fb4a18e fix: delete account 2023-04-06 16:59:15 +03:00
Vlad Stan
f7fb926c52 fix: port value 2023-03-17 17:24:33 +02:00
Vlad Stan
28c0947afb fix: invoice can have undefined tag 2023-03-17 15:24:17 +02:00
Vlad Stan
527afa0c8c feat: wait for paid invoce 2023-03-17 15:06:59 +02:00
Vlad Stan
3aa4875558 chore: code format 2023-03-17 15:06:09 +02:00
Vlad Stan
30ffcf7f55 fix: show wss & invoice generation 2023-03-15 21:19:31 +02:00
Vlad Stan
218b324347 chore: code format 2023-03-15 21:19:31 +02:00
Vlad Stan
6e1b5dd0bb fix: copy button 2023-03-15 21:19:31 +02:00
Vlad Stan
67dda89c81 fix: separate relay update from activete/deactivate 2023-03-15 21:19:31 +02:00
Vlad Stan
63be2b5b2d feat: show wss URL 2023-03-15 21:19:31 +02:00
Vlad Stan
5e313c0282 chore: code format 2023-03-15 21:17:51 +02:00
Vlad Stan
16b9d93dca fix: use Spec for PaymentSpec 2023-02-22 11:39:35 +02:00
Vlad Stan
8df0dc2f52 chore: code clean-up 2023-02-22 11:39:35 +02:00
Vlad Stan
dd9dbbe818 fix: return generic Relay Info 2023-02-21 16:20:19 +02:00
Vlad Stan
f4d4706237 chore: remove test path 2023-02-21 16:20:19 +02:00
Vlad Stan
8c860f851a feat: update supported NIPs 2023-02-21 16:20:19 +02:00
Vlad Stan
abe8c65c4c refactor: rename fields 2023-02-21 16:20:19 +02:00
Vlad Stan
ffb0177003 chore: code format 2023-02-21 16:20:19 +02:00
Vlad Stan
d66184c077 feat: add /api/v1/relay-info endpoint 2023-02-21 16:20:19 +02:00
Vlad Stan
8d316c4887 feat: add redirect_paths 2023-02-21 16:20:19 +02:00
Vlad Stan
729f36e993 refactor: extract relay_info_response 2023-02-21 16:20:19 +02:00
Vlad Stan
2d4e836676
doc: summary 2023-02-17 15:13:37 +02:00
Vlad Stan
7ec3045130 feat: add extension clean-up endpoint 2023-02-17 14:57:31 +02:00
Vlad Stan
230729483c chore: code format 2023-02-17 14:44:39 +02:00
Vlad Stan
855812cb8f refactor: extract NostrRelay 2023-02-17 14:44:39 +02:00
Vlad Stan
2ebc83a286 refactor: extract NostrFilter 2023-02-17 14:44:39 +02:00
Vlad Stan
6be0169ea9 refactor: extract NostrEvent 2023-02-17 14:44:39 +02:00
Vlad Stan
c42d81f696 refactor: extract client_connection 2023-02-17 14:44:39 +02:00
Vlad Stan
aa68d2a79a refactor: extract EventValidator 2023-02-17 14:44:39 +02:00
Vlad Stan
c46c903703 refactor: move client-manager 2023-02-17 14:44:39 +02:00
Vlad Stan
818072fe29 refactor: rename def client_config to def config 2023-02-17 14:44:39 +02:00
Vlad Stan
d5d8b5e1b5 refactor: init callbacks 2023-02-17 14:44:39 +02:00
Vlad Stan
30ab2b8f70 chore: testing 2023-02-17 14:44:39 +02:00
Vlad Stan
8c24109dd3
doc: fix headers 2023-02-17 12:21:02 +02:00
Vlad Stan
eaf0979254
doc: small fixes 2023-02-17 12:19:45 +02:00
Vlad Stan
012d861d16
doc: small fixes 2023-02-17 12:17:47 +02:00
Vlad Stan
bae7d284b3
doc: add accounts 2023-02-17 12:13:39 +02:00
Vlad Stan
d38b3c73ff
doc: add Config 2023-02-17 12:06:07 +02:00
Vlad Stan
de25a7a12d
doc: basic config tab 2023-02-17 11:54:05 +02:00
Vlad Stan
fd18ebe015
doc: payment basics 2023-02-17 11:40:09 +02:00
Vlad Stan
1dc6ddf9c5
doc: create relay 2023-02-17 11:08:32 +02:00
Vlad Stan
afde9ae37c
doc: mark NIP16 done 2023-02-17 09:55:57 +02:00
Vlad Stan
5c0209b6c0 feat: finish NIP16 2023-02-17 09:55:23 +02:00
Vlad Stan
a1d7c474b0 feat: differentiate between publisher and author 2023-02-17 09:38:49 +02:00
Vlad Stan
b5f7aa0c78
doc: updated NIP 04 2023-02-16 18:09:48 +02:00
Vlad Stan
ba191cabbc feat: do not broadcast direct messages in AUTH mode 2023-02-16 17:54:47 +02:00
Vlad Stan
2c0bcce8c7 fix: cal correct action 2023-02-16 17:54:11 +02:00
Vlad Stan
94730ba464 feat: store pubkey when authenticated 2023-02-16 17:04:23 +02:00
Vlad Stan
7d98bc1deb feat: add event kind 41 to replaceable events list 2023-02-16 16:56:40 +02:00
Vlad Stan
a120394304
doc: more nips 2023-02-16 16:56:04 +02:00
Vlad Stan
6130de3987
doc: nip 15 & 16 2023-02-16 16:36:02 +02:00
Vlad Stan
1a300d4528
doc: nore NIP updates 2023-02-16 16:30:30 +02:00
Vlad Stan
1099fe7e87
doc: supported nips 1 2023-02-16 14:50:40 +02:00
Vlad Stan
09707151c5 feat: change rate limit to per hour instead of per second 2023-02-16 14:37:31 +02:00
Vlad Stan
6e67443ea4 feat: small UI adjustments 2023-02-16 14:23:42 +02:00
Vlad Stan
aed098499a chore: code format 2023-02-16 14:19:21 +02:00
Vlad Stan
5a984bddcd feat: mage block/allow accounts 2023-02-16 14:17:21 +02:00
Vlad Stan
2c5dfbbf92 feat: add basic block/allow actions 2023-02-16 12:33:16 +02:00
Vlad Stan
d8ca9f830a chore: code format 2023-02-15 18:19:00 +02:00
Vlad Stan
bc1af610db chore: mypy fixes 2023-02-15 18:18:46 +02:00
Vlad Stan
d0f38346e3 feat: basic account UI 2023-02-15 14:17:01 +02:00
Vlad Stan
2099a8b7bb refactor: allow&block clean-up 2023-02-15 11:58:15 +02:00
Vlad Stan
88d53bd73d feat: respond with challenge to client AUTH message 2023-02-15 11:44:35 +02:00
Vlad Stan
9aa0fdbd2c chore: add 42 to supported NIPs 2023-02-15 11:42:39 +02:00
Vlad Stan
a8a2ef5e27 feat: force auth for particular event types 2023-02-15 11:41:50 +02:00
Vlad Stan
58723a387f chore: code format 2023-02-15 10:49:36 +02:00
Vlad Stan
366dae2082 chore: label update 2023-02-15 10:46:50 +02:00
Vlad Stan
b472e97454 feat: handle auth - see prev commit :) 2023-02-15 10:34:21 +02:00
Vlad Stan
1c81ca300f refactor: extract extract_domain function 2023-02-15 10:33:56 +02:00
Vlad Stan
3648dc212c feat: partial AUTH support 2023-02-14 17:26:40 +02:00
Vlad Stan
d0c6f1392b feat: on create save domain for relay 2023-02-14 10:27:24 +02:00
Vlad Stan
be606934bf fix: missing import 2023-02-14 09:56:57 +02:00
Vlad Stan
44ae8086cc feat: check paid_to_join and storage 2023-02-14 09:46:33 +02:00
Vlad Stan
dfda2367a2 feat: add payment for stoeage 2023-02-13 17:49:10 +02:00
Vlad Stan
2233521a43 feat: ignore some files from release export 2023-02-13 16:44:23 +02:00
Vlad Stan
d0d3f1f6ed fix: double column primary key 2023-02-13 14:41:43 +02:00
Vlad Stan
23547f5187 chore: code format 2023-02-13 14:41:33 +02:00
Vlad Stan
4970334713 refactor: extract method 2023-02-13 14:20:11 +02:00
Vlad Stan
bd5957b443 feat: update account when join invoce paid 2023-02-13 14:12:37 +02:00
Vlad Stan
330fceaf34 feat: small UI improvements 2023-02-13 11:50:18 +02:00
Vlad Stan
b7840ced08 chore: debug log 2023-02-13 10:10:55 +02:00
Vlad Stan
ebada934b0 chore: code format 2023-02-10 17:25:02 +02:00
Vlad Stan
8678090e7b feat: create invoice to join 2023-02-10 17:20:55 +02:00
Vlad Stan
db3ad2e32f feat: public relay page updates 2023-02-10 14:47:48 +02:00
Vlad Stan
ae68f210cd chore: remove old field 2023-02-10 13:41:55 +02:00
Vlad Stan
d5aff47717 refactor: class renamings 2023-02-10 13:40:15 +02:00
Vlad Stan
0c72f868ed fix: do not use lnbits_relay_information 2023-02-10 13:40:01 +02:00
Vlad Stan
9ccd94aae3 feat: basic relay public info 2023-02-10 13:39:38 +02:00
Vlad Stan
1eda457067 chore: force py format 2023-02-10 12:16:25 +02:00
Vlad Stan
55f9142f3d fix: query for SQLite 2023-02-10 12:09:45 +02:00
Vlad Stan
339f2b70c1 feat: restrict storage 2023-02-10 11:43:18 +02:00
Vlad Stan
a2f3951efa fix: message 2023-02-10 10:21:18 +02:00
Vlad Stan
d01084881a fix: UI layout 2023-02-10 10:16:16 +02:00
Vlad Stan
9fef72ae0c doc: add comment 2023-02-10 10:10:01 +02:00
Vlad Stan
f109833a36 feat: get_storage_for_public_key 2023-02-09 15:43:39 +02:00
Vlad Stan
48c21a76d9 fix: UI on small screen 2023-02-09 15:43:08 +02:00
Vlad Stan
c488f5d5e0 feat: store message size 2023-02-09 15:16:22 +02:00
Vlad Stan
f331a19d75 feat: payment UI updates 2023-02-09 14:56:24 +02:00
Vlad Stan
cb81726297 feat: add UI for storage 2023-02-09 14:33:19 +02:00
Vlad Stan
50fd1942cc refactor: extract method _validate_event() 2023-02-09 12:26:37 +02:00
Vlad Stan
f5c873ec4d feat: add support for NIP22 2023-02-09 12:18:54 +02:00
Vlad Stan
868e02d3c2 feat: limit max events per second 2023-02-09 10:28:56 +02:00
Vlad Stan
bddab70677 feat: enforce query limit on relay side 2023-02-08 18:15:43 +02:00
Vlad Stan
b4094ad2f5 feat: limit the number of filters a client can have 2023-02-08 17:51:35 +02:00
Vlad Stan
2cb9d083c6 feat: send message back to owner 2023-02-08 15:07:53 +02:00
Vlad Stan
4b96f65c85 fix: delete relay 2023-02-08 12:58:19 +02:00
Vlad Stan
24f803921e refactor: rename variables 2023-02-08 12:00:01 +02:00
Vlad Stan
9501286c1f refactor: extract method _set_client_callbacks() 2023-02-08 11:58:19 +02:00
Vlad Stan
423364a5c6 refactor: extract method _send_msg() 2023-02-08 11:56:03 +02:00
Vlad Stan
7215d37fe9 feat: more detailed error messages for rejected event 2023-02-08 11:52:07 +02:00
Vlad Stan
5339dde64a fix: revert NostrClientManagerinit place 2023-02-08 11:40:42 +02:00
Vlad Stan
43dc3e85ce feat: check access control list 2023-02-08 11:16:34 +02:00
Vlad Stan
e1fe19b115 refactoring: small 2023-02-08 10:47:43 +02:00
Vlad Stan
30dfb03d2d refactor: rename private fields 2023-02-08 10:26:32 +02:00
Vlad Stan
9c5b7f69dd fix: init relays 2023-02-08 10:23:02 +02:00
Vlad Stan
534cf04210 feat: use RelayConfig for active relays 2023-02-08 10:21:26 +02:00
Vlad Stan
b098dc5d77 feat: clean-up when relay deleted 2023-02-08 09:29:14 +02:00
Vlad Stan
6527d04977 refactor: function renaming 2023-02-08 09:15:26 +02:00
Vlad Stan
d0341911b9 refactor: extract NostrFilter.to_sql_components 2023-02-08 09:10:04 +02:00
Vlad Stan
f97cd1dff6 fix: group clients by relay 2023-02-08 08:59:19 +02:00
Vlad Stan
4e5c2657c9 chore: code format 2023-02-08 08:50:07 +02:00
Vlad Stan
b2b5058784 refactor: extract client_manager 2023-02-08 08:49:52 +02:00
Vlad Stan
14df3a6ffb tests: update after changes 2023-02-07 18:15:43 +02:00
Vlad Stan
25dde9571c feat: save relay extra config 2023-02-07 17:50:48 +02:00
Vlad Stan
f689e829eb feat: add max filters 2023-02-07 16:51:49 +02:00
Vlad Stan
709e201fec feat: add UI for blocked/allowed public keys 2023-02-07 16:41:57 +02:00
Vlad Stan
1e2307a758 fix: ui conditional view 2023-02-07 16:10:52 +02:00
Vlad Stan
43f3fbfb44 feat: add payment options 2023-02-07 16:03:13 +02:00
Vlad Stan
a74cf42feb feat: save relay info updates 2023-02-07 14:57:32 +02:00
Vlad Stan
c05ecb054d feat: edit relay info 2023-02-07 14:44:11 +02:00
Vlad Stan
cab870f6ff fix: only send required info for toggling a relay 2023-02-07 14:31:56 +02:00
Vlad Stan
3fe4984026 feat: add tabs for relay 2023-02-07 14:29:46 +02:00
Vlad Stan
1bc8640640 feat: move logic to relay-details component 2023-02-07 14:24:12 +02:00
Vlad Stan
999bb1683f feat: move toggle relay up 2023-02-07 14:10:20 +02:00
Vlad Stan
f0857a5609 chore: code format 2023-02-07 14:10:03 +02:00
Vlad Stan
4c9d1a4395 feat: init lnbits_relay_information 2023-02-07 12:21:23 +02:00
Vlad Stan
e4197ce66d fix: created_at search 2023-02-07 11:44:26 +02:00
Vlad Stan
4db031c10e feat: close connection to client when relay is deactivated 2023-02-06 17:59:36 +02:00
Vlad Stan
f56e9e2e56 feat: block access to deactivated client 2023-02-06 17:42:27 +02:00
Vlad Stan
dedcf823bd refactor: NIPs endpoint 2023-02-06 17:03:20 +02:00
Vlad Stan
aff949fed5 feat: allow custom relay IDs 2023-02-06 16:58:29 +02:00
Vlad Stan
eedaa52bcf feat: allow admin users to specify custom relay name 2023-02-06 16:43:59 +02:00
Vlad Stan
bc60551313 feat: toggle relays on and off 2023-02-06 16:33:08 +02:00
Vlad Stan
a849dea99f feat: add UI for basic relay operations 2023-02-06 15:30:05 +02:00
Vlad Stan
0db0d9c351 feat: add more fields to relays 2023-02-06 14:23:54 +02:00
Vlad Stan
24795f519f feat: update Create Relay dialog 2023-02-06 14:23:41 +02:00
Vlad Stan
298021d25a feat: add basic UI 2023-02-06 14:03:15 +02:00
Vlad Stan
5a747361af feat: make event kind: 3 replaceable 2023-02-06 12:09:53 +02:00
Vlad Stan
10ef9ee2ac feat: on meta update, replace old meta 2023-02-06 11:10:14 +02:00
Vlad Stan
57197b981d refactor: mark delete using query builder 2023-02-06 10:37:06 +02:00
Vlad Stan
f01ea6a237 refactor: delete using filter 2023-02-06 10:25:28 +02:00
Vlad Stan
2dfd70c38c refactor: query builder 2023-02-06 10:19:47 +02:00
Vlad Stan
282e65954b refactor: query builder 2023-02-06 10:07:00 +02:00
Vlad Stan
5ca27ff28a fix: ok -> OK upper case 2023-02-03 18:17:41 +02:00
Vlad Stan
8640e2c06f feat: signal NIP20 support 2023-02-03 17:43:06 +02:00
Vlad Stan
d27ece735e fix: NIP20 duplicate event 2023-02-03 17:42:10 +02:00
Vlad Stan
a01c392a89 feat: signal NIP15 support 2023-02-03 17:12:39 +02:00
Vlad Stan
cbbabf2ce8 feat: partial NIP11 2023-02-03 16:37:27 +02:00
Vlad Stan
9b9e2623be fix: do not remove delete events 2023-02-03 15:50:29 +02:00
Vlad Stan
01764c155e feat: add delete post tests 2023-02-03 15:45:23 +02:00
Vlad Stan
b0be06580a fat: support NIP09 delete event 2023-02-03 14:26:30 +02:00
Vlad Stan
8645bb6d05 refactor: extract query builder 2023-02-03 13:21:07 +02:00
Vlad Stan
6b11221205 test: direct messages 2023-02-03 11:32:44 +02:00
Vlad Stan
30ed35a14e test: bob writes direct message to alice 2023-02-03 11:07:17 +02:00
Vlad Stan
96f8f9927d refactor: method renames 2023-02-03 10:21:11 +02:00
Vlad Stan
c4b498c28c refactor: make code more readable 2023-02-03 10:14:28 +02:00
Vlad Stan
3d1511a545 refactor: extract fixtures 2023-02-03 09:58:50 +02:00
Vlad Stan
f183027811 fix: missing param 2023-02-03 09:48:16 +02:00
Vlad Stan
78de195354 test: notifications 2023-02-03 09:47:12 +02:00
Vlad Stan
e87d43d21a test: bob_reacts_to_posts_alice_receives_notifications 2023-02-03 09:23:43 +02:00
Vlad Stan
4766e8daaf test: notify likes 2023-02-02 18:43:46 +02:00
Vlad Stan
158bb9b58a fix: serialize emoji’s 2023-02-02 18:43:26 +02:00
54 changed files with 7404 additions and 1060 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
MOCK.md export-ignore

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

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

58
.github/workflows/release.yml vendored Normal file
View 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

4
.gitignore vendored
View file

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

12
.prettierrc Normal file
View file

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

49
Makefile Normal file
View file

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

131
README.md
View file

@ -1,12 +1,131 @@
# 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
2. Enable relay
- [x] **NIP-01**: Basic protocol flow
- [x] Regular Events
- [x] Replaceable Events (kinds 10000-19999)
- [x] Ephemeral Events (kinds 20000-29999)
- [x] Addressable Events (kinds 30000-39999)
- [x] **NIP-02**: Contact List and Petnames
- `kind: 3`: delete past contact lists as soon as the relay receives a new one
- [x] **NIP-04**: Encrypted Direct Message
- 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**
- ![image](https://user-images.githubusercontent.com/2951406/219601417-9292d5b9-d96c-4ff6-a6fd-6c8b37b9872d.png)
## 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**
- ![image](https://user-images.githubusercontent.com/2951406/219601945-f3987de0-ed0c-48d5-b31e-44d8356cfa9a.png)
### 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**
- ![image](https://user-images.githubusercontent.com/2951406/219609779-1513ad00-e816-4b4f-8e1e-459e5e1c586f.png)
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**
- ![image](https://user-images.githubusercontent.com/2951406/219610594-ec2984ca-2c09-4187-91c3-96a25e8b5722.png)
### 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**
- ![image](https://user-images.githubusercontent.com/2951406/219611794-57066899-5bc3-4439-ad98-af6fd4130ee9.png)
### 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**
- ![image](https://user-images.githubusercontent.com/2951406/219615500-8ca98580-dc3d-4163-b321-ae9279d47a98.png)
## Development
Create Symbolic Link:
```
ln -s /Users/my-user/git-repos/nostr-relay-extension/ /Users/my-user/git-repos/lnbits/lnbits/extensions/nostrrelay
```

View file

@ -1,25 +1,59 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from loguru import logger
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_nostrrelay")
from .client_manager import client_manager
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import nostrrelay_generic_router
from .views_api import nostrrelay_api_router
nostrrelay_ext: APIRouter = APIRouter(prefix="/nostrrelay", tags=["NostrRelay"])
nostrrelay_ext.include_router(nostrrelay_generic_router)
nostrrelay_ext.include_router(nostrrelay_api_router)
nostrrelay_static_files = [
{
"path": "/nostrrelay/static",
"app": StaticFiles(directory="lnbits/extensions/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():
return template_renderer(["lnbits/extensions/nostrrelay/templates"])
scheduled_tasks: list[asyncio.Task] = []
from .views import * # noqa
from .views_api import * # noqa
async def nostrrelay_stop():
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
try:
await client_manager.stop()
except Exception as ex:
logger.warning(ex)
def nostrrelay_start():
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",
]

View file

@ -1,107 +1,3 @@
import asyncio
import json
from typing import Any, Callable, List, Union
from .relay.client_manager import NostrClientManager
from fastapi import WebSocket
from loguru import logger
from .crud import create_event, get_events
from .models import NostrEvent, NostrEventType, NostrFilter
class NostrClientManager:
def __init__(self):
self.clients: List["NostrClientConnection"] = []
def add_client(self, client: "NostrClientConnection"):
setattr(client, "broadcast_event", self.broadcast_event)
self.clients.append(client)
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:
sent = await client.notify_event(event)
print("### sent", sent, event.id)
class NostrClientConnection:
broadcast_event: Callable
def __init__(self, websocket: WebSocket):
self.websocket = websocket
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:
print('### sent query', json.dumps(r))
await self.websocket.send_text(json.dumps(r))
except Exception as e:
logger.warning(e)
async def notify_event(self, event: NostrEvent) -> bool:
for filter in self.filters:
if filter.matches(event):
resp = event.serialize_response(filter.subscription_id)
print('### sent notify', json.dumps(resp))
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()
await create_event("111", e)
await self.broadcast_event(self, e)
resp_nip20 += [True, ""]
except Exception as ex:
resp_nip20 += [False, f"error: failed to create event"]
await self.websocket.send_text(json.dumps(resp_nip20))
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("111", 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]
client_manager: NostrClientManager = NostrClientManager()

View file

@ -1,6 +1,42 @@
{
"name": "Nostr Relay",
"version": "1.1.0",
"short_description": "One click launch your own relay!",
"tile": "/nostrrelay/static/image/nostrrelay.png",
"contributors": ["arcbtc", "DCs"]
"tile": "/nostrrelay/static/image/nostrrelay.png",
"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"
}

379
crud.py
View file

@ -1,130 +1,313 @@
import json
from typing import Any, List, Optional
from . import db
from .models import NostrEvent, NostrFilter
from lnbits.db import Database
from .models import NostrAccount, NostrEventTags
from .relay.event import NostrEvent
from .relay.filter import NostrFilter
from .relay.relay import NostrRelay, RelayPublicSpec
db = Database("ext_nostrrelay")
async def create_event(relay_id: str, e: NostrEvent):
await db.execute(
"""
INSERT INTO nostrrelay.events (
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_relay(relay: NostrRelay) -> NostrRelay:
await db.insert("nostrrelay.relays", relay)
return relay
async def update_relay(relay: NostrRelay) -> NostrRelay:
await db.update("nostrrelay.relays", relay, "WHERE user_id = :user_id AND id = :id")
return relay
async def get_relay(user_id: str, relay_id: str) -> NostrRelay | None:
return await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
{"user_id": user_id, "id": relay_id},
NostrRelay,
)
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]:
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_config_for_all_active_relays() -> dict:
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
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 {
**NostrRelay.info(),
"id": relay.id,
"name": relay.name,
"description": relay.description,
"pubkey": relay.pubkey,
"contact": relay.contact,
"config": RelayPublicSpec(**relay.meta.dict()).dict(by_alias=True),
}
async def delete_relay(user_id: str, relay_id: str):
await db.execute(
"DELETE FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
{"user_id": user_id, "id": relay_id},
)
async def create_event(event: NostrEvent):
event_ = await get_event(event.relay_id, event.id)
if event_:
return None
await db.insert("nostrrelay.events", event)
# todo: optimize with bulk insert
for tag in e.tags:
for tag in event.tags:
name, value, *rest = tag
extra = json.dumps(rest) if rest else None
await create_event_tags(relay_id, e.id, name, value, extra)
_tag = NostrEventTags(
relay_id=event.relay_id,
event_id=event.id,
name=name,
value=value,
extra=extra,
)
await create_event_tags(_tag)
async def get_events(relay_id: str, filter: NostrFilter) -> List[NostrEvent]:
values: List[Any] = [relay_id]
query = "SELECT id, pubkey, created_at, kind, content, sig FROM nostrrelay.events "
async def get_events(
relay_id: str, nostr_filter: NostrFilter, include_tags=True
) -> list[NostrEvent]:
inner_joins = []
where = ["nostrrelay.events.relay_id = ?"]
if len(filter.e):
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')")
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
query = f"""
SELECT * FROM nostrrelay.events
{" ".join(inner_joins)}
WHERE { " AND ".join(where)}
ORDER BY created_at DESC
"""
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'")
query += " ".join(inner_joins)+ " WHERE " + " AND ".join(where)
# todo: check & enforce range
if nostr_filter.limit and nostr_filter.limit > 0:
query += f" LIMIT {nostr_filter.limit}"
events = await db.fetchall(query, values, NostrEvent)
if len(filter.ids) != 0:
ids = ",".join(["?"] * len(filter.ids))
query += f" AND id IN ({ids})"
values += filter.ids
if len(filter.authors) != 0:
authors = ",".join(["?"] * len(filter.authors))
query += f" AND pubkey IN ({authors})"
values += filter.authors
if len(filter.kinds) != 0:
kinds = ",".join(["?"] * len(filter.kinds))
query += f" AND kind IN ({kinds})"
values += filter.kinds
if filter.since:
query += " AND created_at >= ?"
values += [filter.since]
if filter.until:
query += " AND created_at <= ?"
values += [filter.until]
query += " ORDER BY created_at DESC"
if filter.limit and type(filter.limit) == int and filter.limit > 0:
query += f" LIMIT {filter.limit}"
rows = await db.fetchall(query, tuple(values))
events = []
for row in rows:
event = NostrEvent.from_row(row)
event.tags = await get_event_tags(relay_id, event.id)
events.append(event)
for event in events:
if include_tags:
event.tags = await get_event_tags(relay_id, event.id)
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:
async def get_event(relay_id: str, event_id: str) -> NostrEvent | None:
event = await db.fetchone(
"SELECT * FROM nostrrelay.events WHERE relay_id = :relay_id AND id = :id",
{"relay_id": relay_id, "id": event_id},
NostrEvent,
)
if not event:
return None
event = NostrEvent.from_row(row)
event.tags = await get_event_tags(relay_id, id)
event.tags = await get_event_tags(relay_id, event_id)
return event
async def create_event_tags(
relay_id: str, event_id: str, tag_name: str, tag_value: str, extra_values: Optional[str]
):
await db.execute(
async def get_storage_for_public_key(relay_id: str, publisher_pubkey: str) -> int:
"""
Returns the storage space in bytes for all the events of a public key.
Deleted events are also counted
"""
row: dict = await db.fetchone(
"""
INSERT INTO nostrrelay.event_tags (
relay_id,
event_id,
name,
value,
extra
)
VALUES (?, ?, ?, ?, ?)
SELECT SUM(size) as sum FROM nostrrelay.events
WHERE relay_id = :relay_id AND publisher = :publisher GROUP BY publisher
""",
(relay_id, event_id, tag_name, tag_value, extra_values),
{"relay_id": relay_id, "publisher": publisher_pubkey},
)
if not row:
return 0
return round(row["sum"])
async def get_prunable_events(relay_id: str, pubkey: str) -> list[tuple[str, int]]:
"""
Return the oldest 10 000 events. Only the `id` and the size are returned,
so the data size should be small
"""
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 get_event_tags(
relay_id: str, event_id: str
) -> List[List[str]]:
rows = await db.fetchall(
"SELECT * FROM nostrrelay.event_tags WHERE relay_id = ? and event_id = ?",
(relay_id, event_id),
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 row in rows:
tag = [row["name"], row["value"]]
extra = row["extra"]
if extra:
tag += json.loads(extra)
tags.append(tag)
tags: list[list[str]] = []
for tag in _tags:
_tag = [tag.name, tag.value]
if tag.extra:
_tag += json.loads(tag.extra)
tags.append(_tag)
return tags
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
View 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
View 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",
},
)

View file

@ -1,9 +1,9 @@
{
"repos": [
{
"id": "nostrrelay",
"organisation": "lnbits",
"repository": "nostr-relay-extension"
}
]
"repos": [
{
"id": "nostrrelay",
"organisation": "lnbits",
"repository": "nostrrelay"
}
]
}

View file

@ -2,12 +2,18 @@ async def m001_initial(db):
"""
Initial nostrrelays tables.
"""
await db.execute(
"""
CREATE TABLE nostrrelay.relays (
user_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL
name TEXT NOT NULL,
description TEXT,
pubkey TEXT,
contact TEXT,
active BOOLEAN DEFAULT false,
meta TEXT NOT NULL DEFAULT '{}'
);
"""
)
@ -16,12 +22,16 @@ async def m001_initial(db):
f"""
CREATE TABLE nostrrelay.events (
relay_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
deleted BOOLEAN DEFAULT false,
publisher TEXT NOT NULL,
id TEXT NOT NULL,
pubkey TEXT NOT NULL,
created_at {db.big_int} NOT NULL,
kind INT 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)
);
"""
)
@ -37,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)
);
"""
)

145
models.py
View file

@ -1,122 +1,45 @@
import hashlib
import json
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from pydantic import BaseModel, Field
from secp256k1 import PublicKey
from pydantic import BaseModel
class NostrRelay(BaseModel):
id: str
wallet: str
name: str
currency: str
tip_options: Optional[str]
tip_wallet: 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
class BuyOrder(BaseModel):
action: str
relay_id: str
pubkey: str
created_at: int
kind: int
tags: List[List[str]] = []
content: str = ""
sig: str
units_to_buy: int = 0
def serialize(self) -> List:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def is_valid_action(self) -> bool:
return self.action in ["join", "storage"]
def serialize_json(self) -> str:
e = self.serialize()
return json.dumps(e, separators=(",", ":"))
class NostrPartialAccount(BaseModel):
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
def event_id(self) -> str:
data = self.serialize_json()
id = hashlib.sha256(data.encode()).hexdigest()
return id
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)]
def can_join(self):
"""If an account is explicitly allowed then it does not need to pay"""
return self.paid_to_join or self.allowed
@classmethod
def from_row(cls, row: Row) -> "NostrEvent":
return cls(**dict(row))
def null_account(cls) -> "NostrAccount":
return NostrAccount(pubkey="", relay_id="")
class NostrFilter(BaseModel):
subscription_id: Optional[str]
ids: List[str] = []
authors: List[str] = []
kinds: List[int] = []
e: List[str] = Field([], alias="#e")
p: List[str] = Field([], alias="#p")
since: Optional[int]
until: Optional[int]
limit: Optional[int]
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
class NostrEventTags(BaseModel):
relay_id: str
event_id: str
name: str
value: str
extra: str | None = None

59
package-lock.json generated Normal file
View file

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

15
package.json Normal file
View file

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

91
pyproject.toml Normal file
View 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
View file

357
relay/client_connection.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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": "",
}

View file

@ -0,0 +1,290 @@
window.app.component('relay-details', {
name: 'relay-details',
template: '#relay-details',
props: ['relay-id', 'adminkey', 'inkey', 'wallet-options'],
data() {
return {
tab: 'info',
relay: null,
accounts: [],
accountPubkey: '',
formDialogItem: {
show: false,
data: {
name: '',
description: ''
}
},
showBlockedAccounts: true,
showAllowedAccounts: false,
accountsTable: {
columns: [
{
name: 'action',
align: 'left',
label: '',
field: ''
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
},
{
name: 'allowed',
align: 'left',
label: 'Allowed',
field: 'allowed'
},
{
name: 'blocked',
align: 'left',
label: 'Blocked',
field: 'blocked'
},
{
name: 'paid_to_join',
align: 'left',
label: 'Paid to join',
field: 'paid_to_join'
},
{
name: 'sats',
align: 'left',
label: 'Spent Sats',
field: 'sats'
},
{
name: 'storage',
align: 'left',
label: 'Storage',
field: 'storage'
}
],
pagination: {
rowsPerPage: 10
}
},
skipEventKind: 0,
forceEventKind: 0
}
},
computed: {
hours() {
const y = []
for (let i = 0; i <= 24; i++) {
y.push(i)
}
return y
},
range60() {
const y = []
for (let i = 0; i <= 60; i++) {
y.push(i)
}
return y
},
storageUnits() {
return ['KB', 'MB']
},
fullStorageActions() {
return [
{value: 'block', label: 'Block New Events'},
{value: 'prune', label: 'Prune Old Events'}
]
},
wssLink() {
this.relay.meta.domain =
this.relay.meta.domain || window.location.hostname
return 'wss://' + this.relay.meta.domain + '/nostrrelay/' + this.relay.id
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
deleteRelay() {
LNbits.utils
.confirmDialog(
'All data will be lost! Are you sure you want to delete this relay?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.adminkey
)
this.$emit('relay-deleted', this.relayId)
Quasar.Notify.create({
type: 'positive',
message: 'Relay Deleted',
timeout: 5000
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
async getRelay() {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.inkey
)
this.relay = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async updateRelay() {
try {
const {data} = await LNbits.api.request(
'PATCH',
'/nostrrelay/api/v1/relay/' + this.relayId,
this.adminkey,
this.relay
)
this.relay = data
this.$emit('relay-updated', this.relay)
this.$q.notify({
type: 'positive',
message: 'Relay Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
togglePaidRelay: async function () {
this.relay.meta.wallet =
this.relay.meta.wallet || this.walletOptions[0].value
},
getAccounts: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrrelay/api/v1/account?relay_id=${this.relay.id}&allowed=${this.showAllowedAccounts}&blocked=${this.showBlockedAccounts}`,
this.inkey
)
this.accounts = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
allowPublicKey: async function (pubkey, allowed) {
await this.updatePublicKey({pubkey, allowed})
},
blockPublicKey: async function (pubkey, blocked = true) {
await this.updatePublicKey({pubkey, blocked})
},
removePublicKey: async function (pubkey) {
LNbits.utils
.confirmDialog('This public key will be removed from relay!')
.onOk(async () => {
await this.deletePublicKey(pubkey)
})
},
togglePublicKey: async function (account, action) {
if (action === 'allow') {
await this.updatePublicKey({
pubkey: account.pubkey,
allowed: account.allowed
})
}
if (action === 'block') {
await this.updatePublicKey({
pubkey: account.pubkey,
blocked: account.blocked
})
}
},
updatePublicKey: async function (ops) {
try {
await LNbits.api.request(
'PUT',
'/nostrrelay/api/v1/account',
this.adminkey,
{
relay_id: this.relay.id,
pubkey: ops.pubkey,
allowed: ops.allowed,
blocked: ops.blocked
}
)
this.$q.notify({
type: 'positive',
message: 'Account Updated',
timeout: 5000
})
this.accountPubkey = ''
await this.getAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deletePublicKey: async function (pubkey) {
try {
await LNbits.api.request(
'DELETE',
`/nostrrelay/api/v1/account/${this.relay.id}/${pubkey}`,
this.adminkey,
{}
)
this.$q.notify({
type: 'positive',
message: 'Account Deleted',
timeout: 5000
})
await this.getAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
addSkipAuthForEvent: function () {
value = +this.skipEventKind
if (this.relay.meta.skipedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.meta.skipedAuthEvents.push(value)
},
removeSkipAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.meta.skipedAuthEvents =
this.relay.meta.skipedAuthEvents.filter(e => e !== value)
},
addForceAuthForEvent: function () {
value = +this.forceEventKind
if (this.relay.meta.forcedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.meta.forcedAuthEvents.push(value)
},
removeForceAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.meta.forcedAuthEvents =
this.relay.meta.forcedAuthEvents.filter(e => e !== value)
},
// todo: bad. base.js not present in custom components
copyText: function (text, message, position) {
Quasar.copyToClipboard(text).then(function () {
Quasar.Notify.create({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
}
},
async created() {
await this.getRelay()
await this.getAccounts()
}
})

BIN
static/image/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/image/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
static/image/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
static/image/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
static/image/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
static/image/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

173
static/js/index.js Normal file
View file

@ -0,0 +1,173 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
filter: '',
relayLinks: [],
formDialogRelay: {
show: false,
data: {
id: '',
name: '',
description: '',
pubkey: '',
contact: ''
}
},
relaysTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'toggle',
align: 'left',
label: 'Active',
field: ''
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
},
{
name: 'contact',
align: 'left',
label: 'Contact',
field: 'contact'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
methods: {
getDefaultRelayData: function () {
return {
id: '',
name: '',
description: '',
pubkey: '',
contact: ''
}
},
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()
}
})

7
static/js/utils.js Normal file
View file

@ -0,0 +1,7 @@
const mapRelay = (obj, oldObj = {}) => {
const relay = {...oldObj, ...obj}
relay.expanded = oldObj.expanded || false
return relay
}

99
tasks.py Normal file
View 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)

View file

@ -0,0 +1,26 @@
<q-card>
<q-card-section>
<p>
Nostr Relay<br />
<small>
Created by,
<a
class="text-secondary"
target="_blank"
style="color: unset"
href="https://github.com/motorina0"
>motorina0</a
></small
>
</p>
<br />
<br />
<a
class="text-secondary"
target="_blank"
href="/docs#/nostrrelay"
class="text-white"
>Swagger REST API Documentation</a
>
</q-card-section>
</q-card>

View file

@ -1,27 +1,124 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="enableRelay"
><div v-if="enabled">Disable relay</div>
<div v-else>Enable relay</div></q-btn
>
<q-btn unelevated color="primary" @click="openCreateRelayDialog()"
>New relay
</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<h6>WebSocket Chat</h6>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Relays</h5>
</div>
<input type="text" id="messageText" autocomplete="off" />
<div class="col q-pr-lg">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn outline color="grey" label="...">
<q-menu auto-close>
<q-list style="min-width: 100px">
<q-item clickable>
<q-item-section @click="exportrelayCSV"
>Export to CSV</q-item-section
>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
<q-table
flat
dense
:rows="relayLinks"
row-key="id"
:columns="relaysTable.columns"
v-model:pagination="relaysTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-btn unelevated color="primary" @click="sendMessage()"
><div>Send</div>
</q-btn>
<ul id="messages"></ul>
<q-td key="id" :props="props">
<a
style="color: unset"
:href="props.row.id"
target="_blank"
v-text="props.row.id"
></a>
</q-td>
<q-td key="toggle" :props="props">
<q-toggle
size="sm"
color="secodary"
v-model="props.row.active"
@update:model-value="showToggleRelayDialog(props.row)"
></q-toggle>
</q-td>
<q-td auto-width v-text="props.row.name"></q-td>
<q-td
key="description"
:props="props"
v-text="props.row.description"
></q-td>
<q-td
key="pubkey"
:props="props"
v-text="props.row.pubkey"
></q-td>
<q-td
key="contact"
:props="props"
v-text="props.row.contact"
></q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<relay-details
:relay-id="props.row.id"
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions"
@relay-deleted="handleRelayDeleted"
@relay-updated="handleRelayUpdated"
></relay-details>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
@ -30,79 +127,83 @@
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} NostrRelay extension
{{SITE_TITLE}} Nostr Relay Extension
</h6>
</q-card-section>
<q-card-section>
<p>
Thiago's Point of Sale is a secure, mobile-ready, instant and
shareable point of sale terminal (PoS) for merchants. The PoS is
linked to your LNbits wallet but completely air-gapped so users can
ONLY create invoices. To share the NostrRelay hit the hash on the
terminal.
</p>
<small
>Created by
<a
class="text-secondary"
href="https://pypi.org/user/dcs/"
target="_blank"
>DCs</a
>,
<a
class="text-secondary"
href="https://github.com/benarc"
target="_blank"
>Ben Arc</a
>.</small
>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrrelay/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialogRelay.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<div class="text-h6">New Relay</div>
</q-card-section>
<q-form @submit="sendFormDataRelay" class="q-gutter-md">
<q-input
v-if="g.user.admin"
filled
dense
v-model.trim="formDialogRelay.data.id"
type="text"
label="*Id"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogRelay.data.name"
type="text"
label="*Name"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogRelay.data.description"
type="text"
label="Description"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogRelay.data.pubkey"
type="text"
label="Relay Public Key"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogRelay.data.contact"
type="text"
label="Contact"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!formDialogRelay.data.name"
type="submit"
>Create Relay</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block vue_templates %}
<template id="relay-details">
{% include("nostrrelay/relay-details.html") %}
</template>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
enabled: false,
ws: null
}
},
methods: {
enableRelay: function () {
// var self = this
// LNbits.api
// .request(
// 'GET',
// '/nostrrelay/api/v1/nostrrelays?all_wallets=true',
// this.g.user.wallets[0].inkey
// )
// .then(function (response) {
// self.nostrrelays = response.data.map(function (obj) {
// return mapNostrRelay(obj)
// })
// })
this.enabled = !this.enabled
},
sendMessage: function (event) {
var input = document.getElementById('messageText')
this.ws.send(input.value)
input.value = ''
}
},
created: function () {
this.ws = new WebSocket('ws://localhost:5000/nostrrelay/client')
this.ws.onmessage = function (event) {
var messages = document.getElementById('messages')
var message = document.createElement('li')
var content = document.createTextNode(event.data)
message.appendChild(content)
messages.appendChild(message)
}
}
})
</script>
<script src="{{ static_url_for('nostrrelay/static', path='js/utils.js') }}"></script>
<script src="{{ static_url_for('nostrrelay/static', path='js/index.js') }}"></script>
<script src="{{ static_url_for('nostrrelay/static', path='components/relay-details.js') }}"></script>
{% endblock %}

View file

@ -1,29 +1,284 @@
{% extends "public.html" %} {% block toolbar_title %} {{ nostrrelay.name }}
<q-btn
flat
dense
size="md"
@click.prevent="urlDialog.show = true"
icon="share"
color="white"
></q-btn>
{% extends "public.html" %} {% block toolbar_title %} LNbits Relay
<q-icon name="sensors" class="q-ml-lg" />
{% endblock %} {% block footer %}{% endblock %} {% block page_container %}
<q-page-container>
<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-container>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
data() {
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>
{% endblock %}

View 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>

0
tests/__init__.py Normal file
View file

53
tests/conftest.py Normal file
View 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"]]

365
tests/fixture/clients.json Normal file
View file

@ -0,0 +1,365 @@
{
"alice": {
"meta": [
"EVENT",
{
"id": "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
"created_at": 1675332095,
"kind": 0,
"tags": [],
"content": "{\"name\":\"Alice\"}",
"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"
],
"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",
{
"id": "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
"created_at": 1675332410,
"kind": 0,
"tags": [],
"content": "{\"name\":\"Bob\"}",
"sig": "52b142eb5bf95e46424d8f146a0efcfd1be35ec2ae446152ccc875bc82eee66bef6df1af9a4456ec8984540ac4e21905544b5291334e2b18a24e534b788b2d81"
}
],
"meta_response": [
"OK",
"a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
true,
""
],
"request_meta_alice": [
"REQ",
"profile",
{
"kinds": [0],
"authors": [
"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,
""
]
}
}

View file

@ -1,12 +0,0 @@
{
"name": "kind 1, emoticon",
"data": {
"kind": 1,
"content": "😁",
"tags": [],
"created_at": 1675241147,
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "906bf8746456ae8583f4def33b8fddca785cecab44ecfa6205a0be97c2576b74",
"sig": "67465c16101f8436a402a7369522fb1138838c012895c8752e942eca2ddf520b2938a508bc5236f633f1d6b4dc61eb19f7271555ca523b8043b65a793d3b208e"
}
},

View file

@ -1,220 +1,239 @@
{
"valid": [
{
"name": "kind 0, metadata",
"data": {
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"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": "kind 1, no tags",
"data": {
"kind": 1,
"content": "i126",
"tags": [],
"created_at": 1675239988,
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
}
},
{
"name": "kind 1, reply, e & p tags",
"data": {
"kind": 1,
"content": "i126 reply",
"tags": [
[
"e",
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"",
"root"
],
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"created_at": 1675240147,
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
}
},
{
"name": "kind 3",
"data": {
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
"created_at": 1675095502,
"kind": 3,
"tags": [
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"content": "",
"sig": "591cf6fd40c6fa6ed0b4ef47e22e52577f786a87aafcd293582076cb3ff75a9598f973fe93de833bb5a793bb3c756a853eab884323257207b2df7d217fabf9e9"
}
},
{
"name": "kind 3, relays",
"data": {
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"created_at": 1675175242,
"kind": 3,
"tags": [
[
"p",
"4b1b856e263836ef4e2ffc439f49b5f0f7b7c4bfc6fba79019ea5f0f648c55d5"
],
[
"p",
"ba6dbec940142c806e5eebe02863968d2037ef50af33fd43b82309165eed1e2a"
],
[
"p",
"ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491"
],
[
"p",
"69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718"
]
],
"content": "{\"wss://lnbits.link/nostrrelay/client\":{\"read\":true,\"write\":true}}",
"sig": "279940c52322467abcfcc10a9123f6e25542a40bc7751fef4b4941de1d5382f2bee7e0fc48a744efc4c227609d619009a0ab4557b36b35ec6df8f71e2e384b3a"
}
},
{
"name": "kind 4, direct message",
"data": {
"kind": 4,
"content": "gw8BFFM6anxgv77elHM5RQ==?iv=w1Qq4gPS3EZ4Csn1NfEgXg==",
"tags": [
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"created_at": 1675240247,
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
}
},
{
"name": "kind 5, delete message",
"data": {
"kind": 5,
"content": "deleted",
"tags": [
[
"e",
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
]
],
"created_at": 1675241034,
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
}
},
{
"name": "kind 6, mention (?)",
"data": {
"kind": 6,
"tags": [
[
"e",
"201eaebc2a3176eefa488558749a7978b5189794550c58aff885c2d362917bda",
"",
"mention"
],
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"content": "#[0]",
"created_at": 1675240471,
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
}
},
{
"name": "kind 7, reaction",
"data": {
"kind": 7,
"content": "+",
"tags": [
[
"e",
"8dacb8a9326d1b8e055386ba7f1ddf9df1cc0dd90ffe3d15802955227c311c14"
],
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"created_at": 1675240377,
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
}
},
{
"name": "kind 30,000, replaceable events, 'd' tag",
"data": {
"kind": 30000,
"tags": [
[
"d",
"chats/null/lastOpened"
]
],
"content": "1675242945",
"created_at": 1675242945,
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
}
}
],
"invalid": [
{
"name": "invalid event id",
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
"data": {
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"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,
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634dbaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
]
"valid": [
{
"name": "kind 0, metadata",
"data": {
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6",
"relay_id": "r1",
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"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": "kind 1, no tags",
"data": {
"kind": 1,
"content": "i126",
"tags": [],
"created_at": 1675239988,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"sig": "b1791d17052cef2a65f487ecd976952a721680da9cda4e0f11f4ea04425b1e0a273b27233a4fc50b9d98ebdf1d0722e52634db9830ba53ad8caeb1e2afc9b7d1"
}
},
{
"name": "kind 1, reply, e & p tags",
"data": {
"kind": 1,
"content": "i126 reply",
"tags": [
[
"e",
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96",
"",
"root"
],
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"created_at": 1675240147,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894",
"sig": "ee855296f691880bac51148996b4200c21da7c8a54c65ab29a83a30bbace3bb5de49f6bdbe8102473211078d006b63bcc67a6e905bf22b3f2195b9e2feaa0957"
}
},
{
"name": "kind 3, contact list",
"data": {
"id": "d1e5db203ef5fb1699f106f132bae1a3b5c9c8acf4fbb6c4a50844a6827164f1",
"relay_id": "r1",
"publisher": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
"pubkey": "69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718",
"created_at": 1675095502,
"kind": 3,
"tags": [
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"content": "",
"sig": "591cf6fd40c6fa6ed0b4ef47e22e52577f786a87aafcd293582076cb3ff75a9598f973fe93de833bb5a793bb3c756a853eab884323257207b2df7d217fabf9e9"
}
},
{
"name": "kind 3, relays",
"data": {
"id": "ee5fd14c3f8198bafbc70250c1c9d773069479ea456e8a11cfd889eb0eb63a9e",
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"created_at": 1675175242,
"kind": 3,
"tags": [
[
"p",
"4b1b856e263836ef4e2ffc439f49b5f0f7b7c4bfc6fba79019ea5f0f648c55d5"
],
[
"p",
"ba6dbec940142c806e5eebe02863968d2037ef50af33fd43b82309165eed1e2a"
],
[
"p",
"ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491"
],
[
"p",
"69795541a6635015b7e18b7f3f0f663fdec952bbd92642ee879610fae2e25718"
]
],
"content": "{\"wss://lnbits.link/nostrrelay/client\":{\"read\":true,\"write\":true}}",
"sig": "279940c52322467abcfcc10a9123f6e25542a40bc7751fef4b4941de1d5382f2bee7e0fc48a744efc4c227609d619009a0ab4557b36b35ec6df8f71e2e384b3a"
}
},
{
"name": "kind 4, direct message",
"data": {
"kind": 4,
"content": "gw8BFFM6anxgv77elHM5RQ==?iv=w1Qq4gPS3EZ4Csn1NfEgXg==",
"tags": [
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"created_at": 1675240247,
"relay_id": "r1",
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "e742abcd1befd0ef51fc047d5bcd3df360bf0d87f29702a333b06cb405ca40e5",
"sig": "eb7269eec350a3a1456261fe4e53a6a58b028497bdfc469c1579940ddcfe29688b420f33b7a9d69d41a9a689e00e661749cde5a44de16a341a8b2be3df6d770d"
}
},
{
"name": "kind 5, delete message",
"data": {
"kind": 5,
"content": "deleted",
"tags": [
[
"e",
"3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
]
],
"created_at": 1675241034,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "31e27bb0133d48b4e27cc23ca533f305fd613b1485d0fc27b3d65354ae7bd4d1",
"sig": "e6f48d78f516212f3272c73eb2a6229b7f4d8254f453d8fe3f225ecf5e1367ed6d15859c678c7d00dee0d6b545fb4967c383b559fe20e59891e229428ed2c312"
}
},
{
"name": "kind 6, mention (?)",
"data": {
"kind": 6,
"tags": [
[
"e",
"201eaebc2a3176eefa488558749a7978b5189794550c58aff885c2d362917bda",
"",
"mention"
],
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"content": "#[0]",
"created_at": 1675240471,
"relay_id": "r1",
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "64e69392dc44972433f80bdb4889d3a5a53b6ba7a18a0f5b9518e0bebfeb202e",
"sig": "6ae812a285be3a0bee8c4ae894bc3a92bbc4a78e03c3b1265e9e4f67668fd2c4fe59af69ab2248e49739e733e270b258384abe45f3b7e2a2fba9caebf405f74e"
}
},
{
"name": "kind 7, reaction",
"data": {
"kind": 7,
"content": "+",
"tags": [
[
"e",
"8dacb8a9326d1b8e055386ba7f1ddf9df1cc0dd90ffe3d15802955227c311c14"
],
[
"p",
"a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
]
],
"created_at": 1675240377,
"relay_id": "r1",
"publisher": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"pubkey": "99a566c374211fd7f3db531f296b574a726329f509fbf3285cf3feac4e9b488e",
"id": "9ad503684485edc2d2c52d024e00d920f50c29e07c7b0e39d221c96f9eecc6da",
"sig": "2619c94b8ae65ac153f287de810a5447bcdd9bf177b149cc1f428a7aa750a3751881bb0ef6359017ab70a45b5062d0be7205fa2c71b6c990e886486a17875947"
}
},
{
"name": "kind 30,000, replaceable events, 'd' tag",
"data": {
"kind": 30000,
"tags": [["d", "chats/null/lastOpened"]],
"content": "1675242945",
"created_at": 1675242945,
"relay_id": "r1",
"publisher": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"pubkey": "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211",
"id": "21248bddbab900b8c2f0713c925519f4f50d71eb081149f71221e69db3a5e2d1",
"sig": "f9be83b62cbbfd6070d434758d3fe7e709947abfff701b240fca5f20fc538f35018be97fd5b236c72f7021845f3a3c805ba878269b5ddf44fe03ec161f60e5d8"
}
}
],
"invalid": [
{
"name": "invalid event id",
"exception": "Invalid event id. Expected: '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acf3440269e3bd2486f6' got '380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa'",
"data": {
"id": "380299ac06ef1bff58e7e5a04a2c5efcd0e15b113e71acaaaaaaaaaaaaaaaaaa",
"relay_id": "r1",
"publisher": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"pubkey": "ae9d97d1627f6d02376cd0ce0ceed716d573deca355649d8e03a9323aaaa2491",
"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"
}
}
]
}

13
tests/helpers.py Normal file
View file

@ -0,0 +1,13 @@
import json
FIXTURES_PATH = "./tests/fixture"
def get_fixtures(file):
"""
Read the content of the JSON file.
"""
with open(f"{FIXTURES_PATH}/{file}.json") as f:
raw_data = json.load(f)
return raw_data

View file

@ -1,331 +1,359 @@
import asyncio
import json
from json import dumps, loads
import pytest
from fastapi import WebSocket
from loguru import logger
from lnbits.extensions.nostrrelay.client_manager import (
from ..relay.client_connection import (
NostrClientConnection,
)
from ..relay.client_manager import (
NostrClientManager,
)
from ..relay.relay import RelaySpec
from .helpers import get_fixtures
fixtures = get_fixtures("clients")
alice = fixtures["alice"]
bob = fixtures["bob"]
def simple_urandom():
# print('### simple_urandom', x)
return 3
fixtures = {
"alice": {
"meta": [
"EVENT",
{
"id": "9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
"pubkey": "0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
"created_at": 1675332095,
"kind": 0,
"tags": [],
"content": '{"name":"Alice"}',
"sig": "95c30b6bbc70f3777d2b2b47ae3961e196eae0df72f3ae301ff1009cdabf9c50bb0eb7825891c842fc6ca5cb268342cc486850a6127ab40df871bd3e1fd0b0d7",
},
],
"meta_response": [
"ok",
"9d4883c31d6ae3d80fd8882a248cc193800a096d87bd55d5c1df8a237e78ca09",
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",
False,
"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",
"sub0",
{
"kinds": [7],
"authors": [
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
],
"limit": 50,
},
],
},
"bob": {
"meta": [
"EVENT",
{
"id": "a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
"created_at": 1675332410,
"kind": 0,
"tags": [],
"content": '{"name":"Bob"}',
"sig": "52b142eb5bf95e46424d8f146a0efcfd1be35ec2ae446152ccc875bc82eee66bef6df1af9a4456ec8984540ac4e21905544b5291334e2b18a24e534b788b2d81",
},
],
"meta_response": [
"ok",
"a3591f44f9f12e8d745a79c19affc1f9ea267a716981116835ddb7b327096be5",
True,
"",
],
"request_meta_alice": [
"REQ",
"profile",
{
"kinds": [0],
"authors": [
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
],
},
],
"request_posts_alice": [
"REQ",
"sub0",
{
"kinds": [1],
"authors": [
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816"
],
"limit": 50,
},
],
"like_post_01": [
"EVENT",
{
"id": "700da4df9029a049ddecd1c586b778f434afb55e56c3016d94334108e3829db7",
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
"created_at": 1675350162,
"kind": 7,
"tags": [
[
"e",
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
],
[
"p",
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
],
],
"content": "❤️",
"sig": "3522d670f2e28bd63d32184aa9617df360684e5bc4b7c791b53c5401437e1bf91d1d335f016076fdee9afa99046dc9cc06a39738b25ff9a1562ac7321e3dca2e",
},
],
"like_post_02": [
"EVENT",
{
"id": "920ee4e856acb3310e64415183da0dd7e2e2b7e7c5a517553b9a75981fbafcc9",
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
"created_at": 1675332450,
"kind": 7,
"tags": [
[
"e",
"79d89e66626c4c54b007259cf068a7ba9416ffb6262cc01ba8e7cebf79b9c0d5",
],
[
"p",
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
],
],
"content": "❤️",
"sig": "90fa8093088ed9280277f10a97c41d68d9f51d24254f7b27c28f5d84ac25426f1bfc217bca0c6712a9965164b07db219ee7e583b94c4d26f00aee87344c3f17a",
},
],
"comment_on_alice_post_01": [
"EVENT",
{
"id": "bb34749ffd3eb0e393e54cc90b61a7dd5f34108d4931467861d20281c0b7daea",
"pubkey": "d685447c43c7c18dbbea61923cf0b63e1ab46bed69b153a48279a95c40bd414a",
"created_at": 1675332468,
"kind": 1,
"tags": [
[
"e",
"05741bda9079cdf66f3be977a4d31287366470d1337b1aeb09506da4fbf7cd85",
],
[
"p",
"0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345749197ca21c8da38d0622816",
],
],
"content": "bob comment 01",
"sig": "f9bb53e2adc27f3a49ec42d681833742e28d734327107ebba3076be226340503048116947a75274e5262fa03aa0430da6fe697e46e19342639ef208e5690d8c5",
},
],
},
}
RELAY_ID = "relay_01"
class MockWebSocket(WebSocket):
def __init__(self):
self.sent_messages = []
self.received_messages = []
self.fake_wire: asyncio.Queue[str] = asyncio.Queue(0)
self.fake_wire = asyncio.Queue(0)
pass
async def accept(self):
async def accept(self, *_, **__):
await asyncio.sleep(0.1)
async def receive_text(self) -> str:
# print("### mock receive_text")
data = await self.fake_wire.get()
self.received_messages.append(data)
return data
async def send_text(self, data: str):
self.sent_messages.append(data)
# print("### mock send_text", data)
async def wire_mock_message(self, data: str):
# print("#### wire_mock_message", data)
await self.fake_wire.put(data)
async def wire_mock_data(self, data: dict):
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
async def test_xxx():
async def test_alice_and_bob():
ws_alice, ws_bob = await init_clients()
await alice_wires_meta_and_post01(ws_alice)
await bob_wires_meta_and_folows_alice(ws_bob)
await bob_wires_contact_list(ws_alice, ws_bob)
await alice_wires_post02_____bob_is_notified(ws_alice, ws_bob)
await bob_likes_post01_____alice_subscribes_and_receives_notifications(
ws_alice, ws_bob
)
await bob_likes_and_comments_____alice_receives_notifications(ws_alice, ws_bob)
await bob_writes_to_alice(ws_alice, ws_bob)
await alice_writes_to_bob(ws_alice, ws_bob)
await alice_deletes_post01__bob_is_notified(ws_alice, ws_bob)
tasks = []
async def init_clients():
client_manager = NostrClientManager()
await client_manager.enable_relay(RELAY_ID, RelaySpec())
ws_alice = MockWebSocket()
client_alice = NostrClientConnection(websocket=ws_alice)
client_manager.add_client(client_alice)
asyncio.create_task(client_alice.start())
client_alice = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_alice)
await client_manager.add_client(client_alice)
task1 = asyncio.create_task(client_alice.start())
tasks.append(task1)
ws_bob = MockWebSocket()
client_bob = NostrClientConnection(websocket=ws_bob)
client_manager.add_client(client_bob)
asyncio.create_task(client_bob.start())
client_bob = NostrClientConnection(relay_id=RELAY_ID, websocket=ws_bob)
await client_manager.add_client(client_bob)
task2 = asyncio.create_task(client_bob.start())
tasks.append(task2)
await asyncio.sleep(1)
await alice_wire_meta_and_post01(ws_alice, fixtures)
# print("### ws_alice.sent_messages", ws_alice.sent_messages)
# # print("### ws_alice.received_messages", ws_alice.received_messages)
# print("### ws_bob.sent_messages", ws_bob.sent_messages)
# print("### ws_bob.received_messages", ws_bob.received_messages)
await bob_wire_meta_and_folow_alice(ws_bob, fixtures)
await alice_post_02_bob_is_notified(ws_alice, ws_bob)
# await ws_bob.wire_mock_message(json.dumps(fixtures["bob"]["like_post_01"]))
# await ws_bob.wire_mock_message(
# json.dumps(fixtures["bob"]["like_alice_post_01"])
# )
# await ws_alice.wire_mock_message(
# json.dumps(fixtures["alice"]["subscribe_reactions_to_me"])
# )
# await asyncio.sleep(0.5)
# await ws_bob.wire_mock_message(
# json.dumps(fixtures["bob"]["like_alice_post_02"])
# )
await asyncio.sleep(0.5)
await asyncio.sleep(1)
return ws_alice, ws_bob
async def alice_wire_meta_and_post01(ws_alice: MockWebSocket, fixtures):
async def alice_wires_meta_and_post01(ws_alice: MockWebSocket):
ws_alice.sent_messages.clear()
await ws_alice.wire_mock_message(json.dumps(fixtures["alice"]["meta"]))
await ws_alice.wire_mock_message(json.dumps(fixtures["alice"]["post01"]))
await ws_alice.wire_mock_message(json.dumps(fixtures["alice"]["post01"]))
await ws_alice.wire_mock_data(alice["meta"])
await ws_alice.wire_mock_data(alice["post01"])
await ws_alice.wire_mock_data(alice["post01"])
await ws_alice.wire_mock_data(alice["meta_update"])
await asyncio.sleep(0.5)
assert (
len(ws_alice.sent_messages) == 3
), "Alice: Expected 3 confirmations to be sent"
assert ws_alice.sent_messages[0] == json.dumps(
fixtures["alice"]["meta_response"]
len(ws_alice.sent_messages) == 4
), "Alice: Expected 4 confirmations to be sent"
assert ws_alice.sent_messages[0] == dumps(
alice["meta_response"]
), "Alice: Wrong confirmation for meta"
assert ws_alice.sent_messages[1] == json.dumps(
fixtures["alice"]["post01_response_ok"]
assert ws_alice.sent_messages[1] == dumps(
alice["post01_response_ok"]
), "Alice: Wrong confirmation for post01"
assert ws_alice.sent_messages[2] == json.dumps(
fixtures["alice"]["post01_response_duplicate"]
), "Alice: Expected failure for double posting"
assert ws_alice.sent_messages[3] == dumps(
alice["meta_update_response"]
), "Alice: Expected confirmation for meta update"
await asyncio.sleep(0.1)
async def bob_wire_meta_and_folow_alice(ws_bob: MockWebSocket, fixtures):
async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket):
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_message(json.dumps(fixtures["bob"]["meta"]))
await ws_bob.wire_mock_message(json.dumps(fixtures["bob"]["request_meta_alice"]))
await ws_bob.wire_mock_message(json.dumps(fixtures["bob"]["request_posts_alice"]))
await ws_bob.wire_mock_data(bob["meta"])
await ws_bob.wire_mock_data(bob["request_meta_alice"])
await ws_bob.wire_mock_data(bob["request_posts_alice"])
await asyncio.sleep(0.5)
assert len(ws_bob.sent_messages) == 5, "Bob: Expected 5 confirmations to be sent"
assert ws_bob.sent_messages[0] == json.dumps(
fixtures["bob"]["meta_response"]
assert ws_bob.sent_messages[0] == dumps(
bob["meta_response"]
), "Bob: Wrong confirmation for meta"
assert ws_bob.sent_messages[1] == json.dumps(
["EVENT", "profile", fixtures["alice"]["meta"][1]]
), "Bob: Wrong confirmation for Alice's meta"
assert ws_bob.sent_messages[2] == json.dumps(
assert ws_bob.sent_messages[1] == dumps(
["EVENT", "profile", alice["meta_update"][1]]
), "Bob: Wrong response for Alice's meta (updated version)"
assert ws_bob.sent_messages[2] == dumps(
["EOSE", "profile"]
), "Bob: Wrong End Of Streaming Event for profile"
assert ws_bob.sent_messages[3] == json.dumps(
["EVENT", "sub0", fixtures["alice"]["post01"][1]]
assert ws_bob.sent_messages[3] == dumps(
["EVENT", "sub0", alice["post01"][1]]
), "Bob: Wrong posts for Alice"
assert ws_bob.sent_messages[4] == json.dumps(
assert ws_bob.sent_messages[4] == dumps(
["EOSE", "sub0"]
), "Bob: Wrong End Of Streaming Event for sub0"
async def alice_post_02_bob_is_notified(ws_alice, ws_bob):
async def bob_wires_contact_list(ws_alice: MockWebSocket, ws_bob: MockWebSocket):
ws_alice.sent_messages.clear()
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["contact_list_create"])
await ws_bob.wire_mock_data(bob["contact_list_update"])
await asyncio.sleep(0.1)
await ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"])
await asyncio.sleep(0.1)
assert (
len(ws_bob.sent_messages) == 2
), "Bob: Expected 1 confirmation for create contact list"
assert ws_bob.sent_messages[0] == dumps(
bob["contact_list_create_response"]
), "Bob: Wrong confirmation for contact list create"
assert ws_bob.sent_messages[1] == dumps(
bob["contact_list_update_response"]
), "Bob: Wrong confirmation for contact list update"
assert (
len(ws_alice.sent_messages) == 2
), "Alice: Expected 3 messages for Bob's contact list"
assert ws_alice.sent_messages[0] == dumps(
["EVENT", "contact", bob["contact_list_update"][1]]
), "Alice: Expected to receive the updated contact list (two items)"
assert ws_alice.sent_messages[1] == dumps(
["EOSE", "contact"]
), "Alice: Wrong End Of Streaming Event for contact list"
async def alice_wires_post02_____bob_is_notified(
ws_alice: MockWebSocket, ws_bob: MockWebSocket
):
ws_bob.sent_messages.clear()
ws_alice.sent_messages.clear()
await ws_alice.wire_mock_message(json.dumps(fixtures["alice"]["post02"]))
await asyncio.sleep(0.5)
print("### ws_alice.sent_messages", ws_alice.sent_messages)
print("### ws_bob.sent_messages", ws_bob.sent_messages)
await ws_alice.wire_mock_data(alice["post02"])
await asyncio.sleep(0.1)
assert ws_alice.sent_messages[0] == json.dumps(
fixtures["alice"]["post02_response_ok"]
assert ws_alice.sent_messages[0] == dumps(
alice["post02_response_ok"]
), "Alice: Wrong confirmation for post02"
assert ws_bob.sent_messages[0] == json.dumps(
["EVENT", "sub0", fixtures["alice"]["post02"][1]]
assert ws_bob.sent_messages[0] == dumps(
["EVENT", "sub0", alice["post02"][1]]
), "Bob: Wrong notification for post02"
async def bob_likes_post01_____alice_subscribes_and_receives_notifications(
ws_alice: MockWebSocket, ws_bob: MockWebSocket
):
ws_alice.sent_messages.clear()
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["like_post01"])
await asyncio.sleep(0.1)
await ws_alice.wire_mock_data(alice["subscribe_reactions_to_me"])
await asyncio.sleep(0.1)
assert (
len(ws_alice.sent_messages) == 2
), "Alice: Expected 2 confirmations to be sent"
assert ws_alice.sent_messages[0] == dumps(
[
"EVENT",
"notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345",
bob["like_post01"][1],
]
), "Alice: must receive 'like' notification"
assert ws_alice.sent_messages[1] == dumps(
["EOSE", "notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345"]
), "Alice: receive stored notifications done"
async def bob_likes_and_comments_____alice_receives_notifications(
ws_alice: MockWebSocket, ws_bob: MockWebSocket
):
ws_alice.sent_messages.clear()
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["like_post02"])
await ws_bob.wire_mock_data(bob["comment_on_alice_post01"])
await asyncio.sleep(0.5)
assert (
len(ws_bob.sent_messages) == 2
), "Bob: Expected 2 confirmations to be sent (for like & comment)"
assert ws_bob.sent_messages[0] == dumps(
bob["like_post02_response"]
), "Bob: Wrong confirmation for like on post02"
assert ws_bob.sent_messages[1] == dumps(
bob["comment_on_alice_post01_response"]
), "Bob: Wrong confirmation for comment on post01"
assert (
len(ws_alice.sent_messages) == 2
), "Alice: Expected 2 notifications to be sent (for like & comment)"
assert ws_alice.sent_messages[0] == dumps(
[
"EVENT",
"notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345",
bob["like_post02"][1],
]
), "Alice: Wrong notification for like on post02"
assert ws_alice.sent_messages[1] == dumps(
[
"EVENT",
"notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345",
bob["comment_on_alice_post01"][1],
]
), "Alice: Wrong notification for comment on post01"
async def bob_writes_to_alice(ws_alice: MockWebSocket, ws_bob: MockWebSocket):
ws_alice.sent_messages.clear()
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["direct_message01"])
await asyncio.sleep(0.1)
assert (
len(ws_bob.sent_messages) == 1
), "Bob: Expected confirmation for direct message"
assert ws_bob.sent_messages[0] == dumps(
bob["direct_message01_response"]
), "Bob: Wrong confirmation for direct message"
assert (
len(ws_alice.sent_messages) == 1
), "Alice: Expected confirmation for direct message"
assert ws_alice.sent_messages[0] == dumps(
[
"EVENT",
"notifications:0b29ecc73ba400e5b4bd1e4cb0d8f524e9958345",
bob["direct_message01"][1],
]
), "Alice: Wrong direct message received"
async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket):
ws_alice.sent_messages.clear()
ws_bob.sent_messages.clear()
await ws_alice.wire_mock_data(alice["direct_message01"])
await asyncio.sleep(0.1)
assert (
len(ws_alice.sent_messages) == 1
), "Alice: Expected confirmation for direct message"
assert ws_alice.sent_messages[0] == dumps(
alice["direct_message01_response"]
), "Alice: Wrong confirmation for direct message"
assert len(ws_bob.sent_messages) == 0, "Bob: no subscription, no message"
await ws_bob.wire_mock_data(bob["subscribe_to_direct_messages"])
await asyncio.sleep(0.5)
assert (
len(ws_bob.sent_messages) == 2
), "Bob: Receive message and EOSE after subscribe"
assert ws_bob.sent_messages[0] == dumps(
[
"EVENT",
"notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed",
alice["direct_message01"][1],
]
), "Bob: Finaly receives direct message from Alice"
assert ws_bob.sent_messages[1] == dumps(
["EOSE", "notifications:d685447c43c7c18dbbea61923cf0b63e1ab46bed"]
), "Bob: Received all stored events"
async def alice_deletes_post01__bob_is_notified(
ws_alice: MockWebSocket, ws_bob: MockWebSocket
):
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["request_posts_alice"])
await asyncio.sleep(0.1)
assert (
len(ws_bob.sent_messages) == 3
), "Bob: Expected two posts from Alice plus and EOSE"
ws_alice.sent_messages.clear()
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["subscribe_to_delete_from_alice"])
await asyncio.sleep(0.1)
await ws_alice.wire_mock_data(alice["delete_post01"])
await asyncio.sleep(0.1)
assert (
len(ws_alice.sent_messages) == 1
), "Alice: Expected confirmation for delete post01"
assert ws_alice.sent_messages[0] == dumps(
alice["delete_post01_response"]
), "Alice: Wrong confirmation for delete post01"
assert len(ws_bob.sent_messages) == 2, "Bob: Expects 2 messages for delete post01"
assert ws_bob.sent_messages[0] == dumps(
["EOSE", "notifications:delete"]
), "Bob: Expect no delete notification on subscribe"
assert loads(ws_bob.sent_messages[1]) == [
"EVENT",
"notifications:delete",
alice["delete_post01"][1],
], "Bob: Expect delete notification later on"
ws_bob.sent_messages.clear()
await ws_bob.wire_mock_data(bob["request_posts_alice"])
await asyncio.sleep(0.1)
assert (
len(ws_bob.sent_messages) == 2
), "Bob: Expected one posts from Alice plus and EOSE"

View file

@ -1,49 +1,37 @@
import json
from typing import List, Optional
import pytest
from loguru import logger
from pydantic import BaseModel
from lnbits.extensions.nostrrelay.crud import create_event, get_event, get_events
from lnbits.extensions.nostrrelay.models import NostrEvent, NostrFilter
from ..crud import (
create_event,
get_event,
get_events,
)
from ..relay.event import NostrEvent
from ..relay.filter import NostrFilter
from .conftest import EventFixture
FIXTURES_PATH = "tests/extensions/nostrrelay/fixture"
RELAY_ID = "r1"
class EventFixture(BaseModel):
name: str
exception: Optional[str]
data: NostrEvent
@pytest.fixture
def valid_events() -> List[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["valid"]]
@pytest.fixture
def invalid_events() -> List[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["invalid"]]
def test_valid_event_id_and_signature(valid_events: List[EventFixture]):
def test_valid_event_id_and_signature(valid_events: list[EventFixture]):
for f in valid_events:
try:
f.data.check_signature()
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
def test_invalid_event_id_and_signature(invalid_events: List[EventFixture]):
def test_invalid_event_id_and_signature(invalid_events: list[EventFixture]):
for f in invalid_events:
with pytest.raises(ValueError, match=f.exception):
f.data.check_signature()
@pytest.mark.asyncio
async def test_valid_event_crud(valid_events: List[EventFixture]):
async def test_valid_event_crud(valid_events: list[EventFixture]):
author = "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
reply_event_id = "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894"
@ -51,14 +39,12 @@ async def test_valid_event_crud(valid_events: List[EventFixture]):
# insert all events in DB before doing an query
for e in all_events:
await create_event(RELAY_ID, e)
await create_event(e)
for f in valid_events:
await get_by_id(f.data, f.name)
await filter_by_id(all_events, f.data, f.name)
await filter_by_author(all_events, author)
await filter_by_tag_p(all_events, author)
@ -69,84 +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)
async def get_by_id(data: NostrEvent, test_name: str):
event = await get_event(RELAY_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 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 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"
async def filter_by_author(all_events: list[NostrEvent], author):
nostr_filter = NostrFilter(authors=[author])
events_by_author = await get_events(RELAY_ID, nostr_filter)
assert len(events_by_author) == 5, "Failed to query by authors"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 5, f"Failed to filter by authors"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 5, "Failed to filter by authors"
async def filter_by_tag_p(all_events: List[NostrEvent], author):
async def filter_by_tag_p(all_events: list[NostrEvent], author):
# todo: check why constructor does not work for fields with aliases (#e, #p)
filter = NostrFilter()
filter.p.append(author)
nostr_filter = NostrFilter()
nostr_filter.p.append(author)
events_related_to_author = await get_events(RELAY_ID, filter)
assert len(events_related_to_author) == 5, f"Failed to query by tag 'p'"
events_related_to_author = await get_events(RELAY_ID, nostr_filter)
assert len(events_related_to_author) == 5, "Failed to query by tag 'p'"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 5, f"Failed to filter by tag 'p'"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 5, "Failed to filter by tag 'p'"
async def filter_by_tag_e(all_events: List[NostrEvent], event_id):
filter = NostrFilter()
filter.e.append(event_id)
async def filter_by_tag_e(all_events: list[NostrEvent], event_id):
nostr_filter = NostrFilter()
nostr_filter.e.append(event_id)
events_related_to_event = await get_events(RELAY_ID, filter)
assert len(events_related_to_event) == 2, f"Failed to query by tag 'e'"
events_related_to_event = await get_events(RELAY_ID, nostr_filter)
assert len(events_related_to_event) == 2, "Failed to query by tag 'e'"
filtered_events = [e for e in all_events if filter.matches(e)]
assert len(filtered_events) == 2, f"Failed to filter by tag 'e'"
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'"
filtered_events = [e for e in all_events if nostr_filter.matches(e)]
assert len(filtered_events) == 2, "Failed to filter by tag 'e'"
def get_fixtures(file):
"""
Read the content of the JSON file.
"""
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)
with open(f"{FIXTURES_PATH}/{file}.json") as f:
raw_data = json.load(f)
return raw_data
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
View file

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

132
tests/test_nip17.py Normal file
View 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
View 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].

2299
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,41 @@
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from . import nostrrelay_ext, nostrrelay_renderer
from .crud import get_public_relay
from .helpers import relay_info_response
templates = Jinja2Templates(directory="templates")
nostrrelay_generic_router: APIRouter = APIRouter()
@nostrrelay_ext.get("/", response_class=HTMLResponse)
def nostrrelay_renderer():
return template_renderer(["nostrrelay/templates"])
@nostrrelay_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return nostrrelay_renderer().TemplateResponse(
"nostrrelay/index.html", {"request": request, "user": user.dict()}
"nostrrelay/index.html", {"request": request, "user": user.json()}
)
@nostrrelay_ext.get("/public")
async def nostrrelay(request: Request, nostrrelay_id):
@nostrrelay_generic_router.get("/{relay_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(
"nostrrelay/public.html",
{
"request": request,
# "nostrrelay": relay,
"web_manifest": f"/nostrrelay/manifest/{nostrrelay_id}.webmanifest",
},
"nostrrelay/public.html", {"request": request, "relay": relay_public_data}
)

View file

@ -1,18 +1,48 @@
from http import HTTPStatus
from fastapi import Query, WebSocket
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 (
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from starlette.responses import JSONResponse
from . import nostrrelay_ext
from .client_manager import NostrClientConnection, NostrClientManager
from .client_manager import client_manager
from .crud import (
create_account,
create_relay,
delete_account,
delete_all_events,
delete_relay,
get_account,
get_accounts,
get_relay,
get_relay_by_id,
get_relays,
update_account,
update_relay,
)
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("/client")
async def websocket_endpoint(websocket: WebSocket):
client = NostrClientConnection(websocket=websocket)
client_manager.add_client(client)
@nostrrelay_api_router.websocket("/{relay_id}")
@nostrrelay_api_router.websocket("/{relay_id}/")
async def websocket_endpoint(relay_id: str, websocket: WebSocket):
client = NostrClientConnection(relay_id=relay_id, websocket=websocket)
client_accepted = await client_manager.add_client(client)
if not client_accepted:
return
try:
await client.start()
except Exception as e:
@ -20,10 +50,223 @@ async def websocket_endpoint(websocket: WebSocket):
client_manager.remove_client(client)
@nostrrelay_ext.get("/api/v1/enable", status_code=HTTPStatus.OK)
async def api_nostrrelay(enable: bool = Query(True)):
return await enable_relay(enable)
@nostrrelay_api_router.post("/api/v1/relay")
async def api_create_relay(
data: NostrRelay,
request: Request,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> NostrRelay:
data.user_id = key_info.wallet.user
if len(data.id):
user = await get_user(data.user_id)
assert user, "User not found."
assert user.admin, "Only admin users can set the relay ID"
else:
data.id = urlsafe_short_hash()[:8]
data.meta.domain = extract_domain(str(request.url))
relay = await create_relay(data)
return relay
async def enable_relay(enable: bool):
return enable
@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:
if relay_id != data.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot change the relay id",
)
relay = await get_relay(wallet.wallet.user, data.id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
updated_relay = NostrRelay.parse_obj({**dict(relay), **dict(data)})
updated_relay.user_id = wallet.wallet.user
updated_relay = await update_relay(updated_relay)
# activate & deactivate have their own endpoint
updated_relay.active = relay.active
if updated_relay.active:
await client_manager.enable_relay(relay_id, updated_relay.meta)
else:
await client_manager.disable_relay(relay_id)
return updated_relay
@nostrrelay_api_router.put("/api/v1/relay/{relay_id}")
async def api_toggle_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> NostrRelay:
relay = await get_relay(wallet.wallet.user, relay_id)
if not relay:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Relay not found",
)
relay.active = not relay.active
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:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Cannot find relay",
)
return relay
@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:
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_all_events(relay_id)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
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}