Compare commits

...

59 Commits
0.1.4 ... 0.4.0

Author SHA1 Message Date
Greg Heartsfield
699489ebaf build: bump version to 0.4.0 2022-01-25 20:56:00 -06:00
Greg Heartsfield
af9da65f71 improvement: upgrade dependencies 2022-01-25 20:55:29 -06:00
Greg Heartsfield
a72eaec3b8 fix: never display hidden events 2022-01-25 20:48:46 -06:00
Greg Heartsfield
f1206e76f2 feat: database reader connection pooling
Added connection pooling for queries, as well as basic configuration
options for min/max connections.
2022-01-25 20:39:24 -06:00
Greg Heartsfield
af453548ee feat: allow author and event id prefix search
This is an experimental non-NIP feature that allows a subscription
filter to include a prefix for authors and events.
2022-01-25 18:23:08 -06:00
Greg Heartsfield
df251c821c docs: updated discord invite link 2022-01-25 07:43:15 -06:00
Greg Heartsfield
2d28a95ff7 feat: allow arbitrary tag queries
This is an experimental feature, outside of any NIP, that demonstrates
generic tag queries.

Instead of limiting subscription filters to just querying only "e" or
"p" tags (via `#e` or `#p` attributes), any tag can be queried.

As an example, consider an event which uses a tag "url".  With this
modification, a subscription filter could add a top-level field
"#url", with an array of strings as the key.  Exact matches would be
returned.

A NIP is forthcoming to formalize this.
2022-01-22 21:29:15 -06:00
Greg Heartsfield
8c93ef5bc2 docs: provide public docker hub link 2022-01-20 22:02:42 -06:00
Greg Heartsfield
1c0fc1326d docs: add timeout for reverse-proxy example 2022-01-19 21:19:12 -06:00
Raj
179928378e refactor: add strictly typed tags
* Add custom error variant

This can be useful to propagate errors not conforming to available
variants. Also to convert other errors in `crate::Error` without having
explicit conversion defined, with `error.to_string()`

* Implement `Tag` and define protocol serialization

A Tag structure have been implemented with dedicated field types. Then
custom serde serialization is derived to map the structure to current
protocol json array as per NIP01.

This adds compile and run time type checking to always ensure wrong
string data are never stored or processed. With strict typed fields and
custom serde derivation this checks can be done at time of serialization,
saving work for internal handling of the actual data.

tests for possible data violations are added, and gives good example of
kind of errors it will through for different cases.

* Use String for URL
2022-01-19 07:42:58 -06:00
Raj
c605d75bb4 docs: update readme to include the new discord server 2022-01-17 08:35:13 -06:00
Greg Heartsfield
81e4e2b892 feat: add supported NIPs (2, 11) to relay info 2022-01-16 08:37:21 -06:00
Greg Heartsfield
6f166433b5 fix: test failures 2022-01-16 08:36:52 -06:00
Greg Heartsfield
030b64de62 feat: replace email with contact field in relay info.
This finalizes the NIP-11 spec implementation.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/21.
2022-01-16 08:34:19 -06:00
Greg Heartsfield
c7eadb1154 Add feature list to README 2022-01-16 08:16:42 -06:00
Greg Heartsfield
62dc77369d docs: rename example relay server 2022-01-15 11:43:12 -06:00
Greg Heartsfield
24587435ca docs: reverse proxy example 2022-01-15 11:41:31 -06:00
Greg Heartsfield
a3124ccea4 improvement: better sql error handling 2022-01-15 09:42:53 -06:00
Greg Heartsfield
4e51e61d16 improvement: display rate limit messages max once per sec 2022-01-15 09:42:17 -06:00
Raj
5c8390bbe0 fix: fix some test failures 2022-01-14 14:27:12 -06:00
Greg Heartsfield
da7968efef fix: restore working websocket message size configuration options 2022-01-05 17:41:12 -05:00
Greg Heartsfield
7037555516 improvement: add indexed tag queries 2022-01-05 17:33:53 -05:00
Greg Heartsfield
19ed990c57 refactor: fix clippy errors for relay info response 2022-01-05 10:10:44 -05:00
Greg Heartsfield
d78bbfc290 build: bump version to 0.3.3 2022-01-03 22:07:15 -05:00
Greg Heartsfield
2924da88bc feat: incorporated improvements from NIP-11 discussion
Change descr to description.  Add `id` for websocket URL.  Use
integers for supported NIPs instead of strings.  Top-level is object,
instead of the array before.
2022-01-03 22:03:30 -05:00
Greg Heartsfield
3024e9fba4 build: bump version to 0.3.2 2022-01-03 18:43:17 -05:00
Greg Heartsfield
d3da4eb009 feat: implementation of proposed NIP-11 (server metadata) 2022-01-03 18:42:24 -05:00
Greg Heartsfield
19637d612e build: bump version to 0.3.1 2022-01-01 19:26:15 -06:00
Greg Heartsfield
afc9a0096a improvement: logging failed queries and timing 2022-01-01 19:25:09 -06:00
Greg Heartsfield
3d56262386 build: bump version to 0.3.0 2022-01-01 18:40:57 -06:00
Greg Heartsfield
6673fcfd11 feat: implement multi-valued filter searching
NIP-01 now uses arrays instead of scalars.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/17
2022-01-01 18:38:52 -06:00
Greg Heartsfield
b5da3fa2b0 docs: link to docker hub 2022-01-01 12:27:09 -06:00
Greg Heartsfield
850957213e build: bump version to 0.2.3 2022-01-01 09:13:13 -06:00
Greg Heartsfield
1aa5a5458d improvement: event signature validation is 100x faster
Switched to latest (git) release of secp256k1, which has more
efficient verification-only context for Schnorr.  Switched to single
pre-instantiated instance of the verifier.
2022-01-01 09:08:19 -06:00
Greg Heartsfield
620e227699 fix: connection issues with Firefox
This adds Hyper, and a 200 response code.  Prior to this, Firefox
would fail to connect.  There is also a text document displayed at the
root URL to indicate this is a Nostr relay.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/15
2022-01-01 08:11:20 -06:00
Greg Heartsfield
14e59ed278 build: bump version to 0.2.2 2021-12-31 16:34:52 -06:00
Greg Heartsfield
5ad383f257 fix: incorrect logic on empty filters for hidden events 2021-12-31 16:34:10 -06:00
Greg Heartsfield
9710ea27aa build: bump version to 0.2.1 2021-12-31 15:38:58 -06:00
Greg Heartsfield
783a6e1042 docs: fix docker examples 2021-12-31 15:28:26 -06:00
Greg Heartsfield
4171a8870e feat: reject events that are too large
A new configuration setting controls the maximum size of event
messages, and sends a notice to the client if they exceed it.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/14
2021-12-31 15:19:35 -06:00
Greg Heartsfield
8f3891c781 docs: docker and config updates 2021-12-31 14:08:04 -06:00
Greg Heartsfield
415d32299b fix: docker run references the correct database file 2021-12-31 14:05:11 -06:00
Greg Heartsfield
5a19a8876f feat: allow database directory configuration
Adds configuration options for database directory, either on command
line through (--db dir-name) or the config.toml file.

Fixes: https://todo.sr.ht/~gheartsfield/nostr-rs-relay/13
2021-12-31 11:51:57 -06:00
Greg Heartsfield
f7f12a7984 build: bump version to 0.2.0 2021-12-30 21:27:48 -06:00
Greg Heartsfield
20ee5a054c feat: rate limit event creation
A configuration option, `messages_per_sec`, imposes a global limit on
the rate for which new events can be stored.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/6
2021-12-30 21:07:21 -06:00
Greg Heartsfield
c60519de23 feat: hide older contact update events
Type 3 (NIP-02) contact lists are hidden when newer ones are submitted
for the same author.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/4
2021-12-30 15:45:03 -06:00
Greg Heartsfield
d72e7a57b6 feat: hide older metadata update events
This updates the database schema to support hiding events.  In this
case, we are hiding older metadata updates when an author provides an
updated event.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/11
2021-12-30 13:55:05 -06:00
Greg Heartsfield
6447ddd974 fix: compile error with missing import 2021-12-30 10:00:34 -06:00
Greg Heartsfield
079722ddd9 improvement: reduce logging level for rejected events 2021-12-30 06:35:36 -06:00
Greg Heartsfield
3302fb2e81 refactor: clippy suggestions 2021-12-29 22:49:46 -06:00
Greg Heartsfield
f415295184 feat: reject future-dated events
If configured, reject events than are more than N seconds in the
future.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/5
2021-12-29 22:47:31 -06:00
Greg Heartsfield
d730bf0c59 feat: add configuration through file
A file named `config.toml` can now be used to load the address, port,
and some websocket configuration settings.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/3
2021-12-29 22:13:02 -06:00
Greg Heartsfield
2e2e01203b build: bump version to 0.1.6 2021-12-23 21:44:12 -06:00
Greg Heartsfield
100f890284 feat: add until for request filters
This implements an additional filter criteria for selecting events
prior to some timestamp.

See https://github.com/fiatjaf/nostr/issues/39x
2021-12-23 21:38:32 -06:00
Greg Heartsfield
0e288fe678 feat: send messages in order of oldest to newest 2021-12-23 21:36:46 -06:00
Greg Heartsfield
bfc804e18c feat: debug protocol messages 2021-12-23 21:30:04 -06:00
Greg Heartsfield
8a8ee5c425 build: bump version to 0.1.5 2021-12-19 16:45:17 -06:00
Greg Heartsfield
55bb6bd440 feat: add resource limits for websocket messages 2021-12-19 16:26:32 -06:00
Greg Heartsfield
7933abaa48 fix: allow unknown fields, like author 2021-12-19 16:18:03 -06:00
17 changed files with 2320 additions and 310 deletions

631
Cargo.lock generated
View File

@@ -22,6 +22,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "atty"
version = "0.2.14"
@@ -57,9 +63,15 @@ version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce18265ec2324ad075345d5814fbeed4f41f0a660055dc78840b74d19b874b1"
dependencies = [
"serde",
"serde 1.0.136",
]
[[package]]
name = "bitcoin_hashes"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "006cc91e1a1d99819bc5b8214be3555c1f0611b169f527a1fdc54ed1f2b745b0"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -75,6 +87,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899"
[[package]]
name = "byteorder"
version = "1.4.3"
@@ -108,6 +126,22 @@ dependencies = [
"bitflags",
]
[[package]]
name = "config"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1b9d958c2b1368a663f05538fc1b5975adce1e19f435acceae987aceeeb369"
dependencies = [
"lazy_static",
"nom",
"rust-ini",
"serde 1.0.136",
"serde-hjson",
"serde_json",
"toml",
"yaml-rust",
]
[[package]]
name = "cpufeatures"
version = "0.2.1"
@@ -117,6 +151,26 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcae03edb34f947e64acdb1c33ec169824e20657e9ecb61cef6c8c74dcb8120"
dependencies = [
"cfg-if",
"lazy_static",
]
[[package]]
name = "dashmap"
version = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c"
dependencies = [
"cfg-if",
"num_cpus",
]
[[package]]
name = "digest"
version = "0.9.0"
@@ -175,9 +229,9 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "futures"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cd0210d8c325c245ff06fd95a3b13689a1a276ac8cfa8e8720cb840bfb84b9e"
checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4"
dependencies = [
"futures-channel",
"futures-core",
@@ -190,9 +244,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fc8cd39e3dbf865f7340dce6a2d401d24fd37c6fe6c4f0ee0de8bfca2252d27"
checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b"
dependencies = [
"futures-core",
"futures-sink",
@@ -200,15 +254,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445"
checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7"
[[package]]
name = "futures-executor"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b808bf53348a36cab739d7e04755909b9fcaaa69b7d7e588b37b6ec62704c97"
checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a"
dependencies = [
"futures-core",
"futures-task",
@@ -217,15 +271,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e481354db6b5c353246ccf6a728b0c5511d752c08da7260546fc0933869daa11"
checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2"
[[package]]
name = "futures-macro"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89f17b21645bc4ed773c69af9c9a0effd4a3f1a3876eadd453469f8854e7fdd"
checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c"
dependencies = [
"proc-macro2",
"quote",
@@ -234,21 +288,27 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "996c6442437b62d21a32cd9906f9c41e7dc1e19a9579843fad948696769305af"
checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508"
[[package]]
name = "futures-task"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12"
checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72"
[[package]]
name = "futures-timer"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c"
[[package]]
name = "futures-util"
version = "0.3.18"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e"
checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164"
dependencies = [
"futures-channel",
"futures-core",
@@ -264,9 +324,9 @@ dependencies = [
[[package]]
name = "generic-array"
version = "0.14.4"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803"
dependencies = [
"typenum",
"version_check",
@@ -274,15 +334,51 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "governor"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df0ee4b237afb71e99f7e2fbd840ffec2d6c4bb569f69b2af18aa1f63077d38"
dependencies = [
"dashmap",
"futures",
"futures-timer",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"quanta",
"rand 0.8.4",
"smallvec",
]
[[package]]
name = "h2"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9de88456263e249e241fcd211d3954e2c9b0ef7ccfc235a444eb367cae3689"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.11.2"
@@ -318,13 +414,24 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1323096b05d41827dadeaee54c9981958c0f94e670bc94ed80037d1a7b8b186b"
checksum = "31f4c6746584866f0feabcc69893c5b51beef3831656a968ed7ae254cdc4fd03"
dependencies = [
"bytes",
"fnv",
"itoa",
"itoa 1.0.1",
]
[[package]]
name = "http-body"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6"
dependencies = [
"bytes",
"http",
"pin-project-lite",
]
[[package]]
@@ -333,12 +440,42 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503"
[[package]]
name = "httpdate"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "0.14.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ec3e62bdc98a2f0393a5048e4c30ef659440ea6e0e572965103e72bd836f55"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa 0.4.8",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "idna"
version = "0.2.3"
@@ -350,6 +487,16 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223"
dependencies = [
"autocfg 1.0.1",
"hashbrown",
]
[[package]]
name = "instant"
version = "0.1.12"
@@ -366,10 +513,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
[[package]]
name = "libc"
version = "0.2.111"
name = "itoa"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e167738f1866a7ec625567bae89ca0d44477232a4f7c52b1c7f2adc2c98804f"
checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
[[package]]
name = "js-sys"
version = "0.3.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lexical-core"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec",
"bitflags",
"cfg-if",
"ryu",
"static_assertions",
]
[[package]]
name = "libc"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0005d08a8f7b65fb8073cb697aa0b12b631ed251ce73d862ce50eeb52ce3b50"
[[package]]
name = "libsqlite3-sys"
@@ -381,6 +562,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "lock_api"
version = "0.4.5"
@@ -399,6 +586,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "mach"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa"
dependencies = [
"libc",
]
[[package]]
name = "matches"
version = "0.1.9"
@@ -434,18 +630,48 @@ dependencies = [
]
[[package]]
name = "nostr-rs-relay"
version = "0.1.4"
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nom"
version = "5.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
dependencies = [
"bitcoin_hashes",
"lexical-core",
"memchr",
"version_check",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nostr-rs-relay"
version = "0.4.0"
dependencies = [
"bitcoin_hashes 0.9.7",
"config",
"env_logger",
"futures",
"futures-util",
"governor",
"hex",
"hyper",
"lazy_static",
"log",
"nonzero_ext",
"r2d2",
"r2d2_sqlite",
"rusqlite",
"secp256k1",
"serde",
"serde 1.0.136",
"serde_json",
"thiserror",
"tokio",
@@ -464,10 +690,28 @@ dependencies = [
]
[[package]]
name = "num_cpus"
version = "1.13.0"
name = "num-traits"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3"
checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31"
dependencies = [
"num-traits 0.2.14",
]
[[package]]
name = "num-traits"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
dependencies = [
"autocfg 1.0.1",
]
[[package]]
name = "num_cpus"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
dependencies = [
"hermit-abi",
"libc",
@@ -475,9 +719,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.8.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5"
[[package]]
name = "opaque-debug"
@@ -518,9 +762,9 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
[[package]]
name = "pin-project-lite"
version = "0.2.7"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443"
checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c"
[[package]]
name = "pin-utils"
@@ -536,28 +780,65 @@ checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe"
[[package]]
name = "ppv-lite86"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba"
checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872"
[[package]]
name = "proc-macro2"
version = "1.0.33"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb37d2df5df740e582f28f8560cf425f52bb267d872fe58358eadb554909f07a"
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.10"
name = "quanta"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
checksum = "20afe714292d5e879d8b12740aa223c6a88f118af41870e8b6196e39a02238a8"
dependencies = [
"crossbeam-utils",
"libc",
"mach",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quote"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r2d2"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545c5bc2b880973c9c10e4067418407a0ccaa3091781d1671d46eb35107cb26f"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "r2d2_sqlite"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ca3c9468a76fc2ad724c486a59682fc362efeac7b18d1c012958bc19f34800"
dependencies = [
"r2d2",
"rusqlite",
]
[[package]]
name = "rand"
version = "0.6.5"
@@ -704,6 +985,15 @@ dependencies = [
"rand_core 0.3.1",
]
[[package]]
name = "raw-cpuid"
version = "10.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929f54e29691d4e6a9cc558479de70db7aa3d98cd6fe7ab86d7507aa2886b9d2"
dependencies = [
"bitflags",
]
[[package]]
name = "rdrand"
version = "0.4.0"
@@ -755,10 +1045,25 @@ dependencies = [
]
[[package]]
name = "ryu"
version = "1.0.7"
name = "rust-ini"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "254df5081ce98661a883445175e52efe99d1cb2a5552891d965d2f5d0cad1c16"
checksum = "3e52c148ef37f8c375d49d5a73aa70713125b7f19095948a923f80afdeb22ec2"
[[package]]
name = "ryu"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
[[package]]
name = "scheduled-thread-pool"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f74fd1204073fa02d5d5d68bec8021be4c38690b61264b2fdb48083d0e7d7"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
@@ -769,38 +1074,54 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "secp256k1"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d03ceae636d0fed5bae6a7f4f664354c5f4fcedf6eef053fef17e49f837d0a"
source = "git+https://github.com/rust-bitcoin/rust-secp256k1.git?rev=50034ccb18fdd84904ab3aa6c84a12fcced33209#50034ccb18fdd84904ab3aa6c84a12fcced33209"
dependencies = [
"bitcoin_hashes",
"bitcoin_hashes 0.10.0",
"rand 0.6.5",
"secp256k1-sys",
"serde",
"serde 1.0.136",
]
[[package]]
name = "secp256k1-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "827cb7cce42533829c792fc51b82fbf18b125b45a702ef2c8be77fce65463a7b"
source = "git+https://github.com/rust-bitcoin/rust-secp256k1.git?rev=50034ccb18fdd84904ab3aa6c84a12fcced33209#50034ccb18fdd84904ab3aa6c84a12fcced33209"
dependencies = [
"cc",
]
[[package]]
name = "serde"
version = "1.0.131"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ad69dfbd3e45369132cc64e6748c2d65cdfb001a2b1c232d128b4ad60561c1"
checksum = "9dad3f759919b92c3068c696c15c3d17238234498bbdcc80f2c469606f948ac8"
[[package]]
name = "serde"
version = "1.0.136"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.131"
name = "serde-hjson"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b710a83c4e0dff6a3d511946b95274ad9ca9e5d3ae497b63fda866ac955358d2"
checksum = "6a3a4e0ea8a88553209f6cc6cfe8724ecad22e1acf372793c27d995290fe74f8"
dependencies = [
"lazy_static",
"num-traits 0.1.43",
"regex",
"serde 0.8.23",
]
[[package]]
name = "serde_derive"
version = "1.0.136"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9"
dependencies = [
"proc-macro2",
"quote",
@@ -809,13 +1130,14 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.72"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527"
checksum = "d23c1ba4cf0efd44be32017709280b32d1cea5c3f1275c3b6d9e8bc54f758085"
dependencies = [
"itoa",
"indexmap",
"itoa 1.0.1",
"ryu",
"serde",
"serde 1.0.136",
]
[[package]]
@@ -848,15 +1170,31 @@ checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5"
[[package]]
name = "smallvec"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309"
checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
[[package]]
name = "socket2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f82496b90c36d70af5fcd482edaa2e0bd16fade569de1330405fecbbdac736b"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "1.0.82"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59"
checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b"
dependencies = [
"proc-macro2",
"quote",
@@ -909,11 +1247,10 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
[[package]]
name = "tokio"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e992e41e0d2fb9f755b37446f20900f64446ef54874f40a60c78f021ac6144"
checksum = "fbbf1c778ec206785635ce8ad57fe52b3009ae9e0c9f574a728f3049d3e55838"
dependencies = [
"autocfg 1.0.1",
"bytes",
"libc",
"memchr",
@@ -929,9 +1266,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9efc1aba077437943f7515666aa2b882dfabfbfdf89c819ea75a8d6e9eaba5e"
checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7"
dependencies = [
"proc-macro2",
"quote",
@@ -950,6 +1287,61 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e99e1983e5d376cd8eb4b66604d2e99e79f5bd988c3055891dcd8c9e2604cc0"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"log",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
dependencies = [
"serde 1.0.136",
]
[[package]]
name = "tower-service"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6"
[[package]]
name = "tracing"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105"
dependencies = [
"cfg-if",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4"
dependencies = [
"lazy_static",
]
[[package]]
name = "try-lock"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
[[package]]
name = "tungstenite"
version = "0.16.0"
@@ -971,9 +1363,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unicode-bidi"
@@ -1031,9 +1423,19 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "want"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0"
dependencies = [
"log",
"try-lock",
]
[[package]]
name = "wasi"
@@ -1041,6 +1443,70 @@ version = "0.10.2+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
[[package]]
name = "wasm-bindgen"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca"
dependencies = [
"bumpalo",
"lazy_static",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2"
[[package]]
name = "web-sys"
version = "0.3.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1071,3 +1537,12 @@ name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "nostr-rs-relay"
version = "0.1.4"
version = "0.4.0"
edition = "2021"
[dependencies]
@@ -13,10 +13,16 @@ tokio-tungstenite = "^0.16"
tungstenite = "^0.16"
thiserror = "^1"
uuid = { version = "^0.8", features = ["v4"] }
config = { version = "0.11", features = ["toml"] }
bitcoin_hashes = { version = "^0.9", features = ["serde"] }
secp256k1 = { version = "^0.20", features = ["rand", "rand-std", "serde", "bitcoin_hashes"] }
secp256k1 = {git = "https://github.com/rust-bitcoin/rust-secp256k1.git", rev = "50034ccb18fdd84904ab3aa6c84a12fcced33209", features = ["rand", "rand-std", "serde", "bitcoin_hashes"] }
serde = { version = "^1.0", features = ["derive"] }
serde_json = "^1.0"
serde_json = {version = "^1.0", features = ["preserve_order"]}
hex = "^0.4"
rusqlite = "^0.26"
rusqlite = { version = "^0.26", features = ["limits"]}
r2d2 = "^0.8"
r2d2_sqlite = "^0.19"
lazy_static = "^1.4"
governor = "^0.4"
nonzero_ext = "^0.3"
hyper={ version="0.14", features=["server","http1","http2","tcp"] }

View File

@@ -15,7 +15,6 @@ RUN cargo build --release
FROM debian:buster-slim
ARG APP=/usr/src/app
ARG APP_DATA=/usr/src/app/db
RUN apt-get update \
&& apt-get install -y ca-certificates tzdata sqlite3 \
&& rm -rf /var/lib/apt/lists/*
@@ -35,9 +34,9 @@ COPY --from=builder /nostr-rs-relay/target/release/nostr-rs-relay ${APP}/nostr-r
RUN chown -R $APP_USER:$APP_USER ${APP}
USER $APP_USER
WORKDIR ${APP_DATA}
WORKDIR ${APP}
ENV RUST_LOG=info
ENV APP_DATA=${APP_DATA}
CMD ["../nostr-rs-relay"]
CMD ./nostr-rs-relay --db ${APP_DATA}

View File

@@ -8,19 +8,38 @@ The project master repository is available on
[sourcehut](https://sr.ht/~gheartsfield/nostr-rs-relay/), and is
mirrored on [GitHub](https://github.com/scsibug/nostr-rs-relay).
## Features
NIPs with relay-specific implementation requirements are listed here.
- [x] NIP-01: Core event model
- [x] NIP-01: Hide old metadata events
- [x] NIP-02: Hide old contact list events
- [ ] NIP-03: OpenTimestamps
- [ ] NIP-09: Event deletion
- [x] NIP-11: Relay information document
## Quick Start
The provided `Dockerfile` will compile and build the server application. Use a bind mount to store the SQLite database outside of the container image, and map the container's 8080 port to a host port (8090 in the example below).
The provided `Dockerfile` will compile and build the server
application. Use a bind mount to store the SQLite database outside of
the container image, and map the container's 8080 port to a host port
(7000 in the example below).
```console
$ docker build -t nostr-rs-relay .
$ docker run -p 8090:8080 --mount src=$(pwd)/nostr_data,target=/usr/src/app/db,type=bind nostr-rs-relay
[2021-12-12T04:20:47Z INFO nostr_rs_relay] Listening on: 0.0.0.0:8080
[2021-12-12T04:20:47Z INFO nostr_rs_relay::db] Opened database for writing
[2021-12-12T04:20:47Z INFO nostr_rs_relay::db] init completed
$ docker run -it -p 7000:8080 \
--mount src=$(pwd)/data,target=/usr/src/app/db,type=bind nostr-rs-relay
[2021-12-31T19:58:31Z INFO nostr_rs_relay] listening on: 0.0.0.0:8080
[2021-12-31T19:58:31Z INFO nostr_rs_relay::db] opened database "/usr/src/app/db/nostr.db" for writing
[2021-12-31T19:58:31Z INFO nostr_rs_relay::db] DB version = 2
```
Use a `nostr` client such as [`noscl`](https://github.com/fiatjaf/noscl) to publish and query events.
Use a `nostr` client such as
[`noscl`](https://github.com/fiatjaf/noscl) to publish and query
events.
```console
$ noscl publish "hello world"
@@ -31,6 +50,35 @@ Text Note [81cf...2652] from 296a...9b92 5 seconds ago
hello world
```
A pre-built container is also available on DockerHub:
https://hub.docker.com/r/scsibug/nostr-rs-relay
## Configuration
The sample [`config.toml`](config.toml) file demonstrates the
configuration available to the relay. This file is optional, but may
be mounted into a docker container like so:
```console
$ docker run -it -p 7000:8080 \
--mount src=$(pwd)/config.toml,target=/usr/src/app/config.toml,type=bind \
--mount src=$(pwd)/data,target=/usr/src/app/db,type=bind \
nostr-rs-relay
```
Options include rate-limiting, event size limits, and network address
settings.
## Reverse Proxy Configuration
For examples of putting the relay behind a reverse proxy (for TLS
termination, load balancing, and other features), see [Reverse
Proxy](reverse-proxy.md).
## Dev Channel
The current dev discussions for this project is happening at https://discord.gg/ufG6fH52Vk.
Drop in to query any development related questions.
License
---
This project is MIT licensed.

67
config.toml Normal file
View File

@@ -0,0 +1,67 @@
# Nostr-rs-relay configuration
[info]
# The advertised URL for the Nostr websocket.
relay_url = "wss://nostr.example.com/"
# Relay information for clients. Put your unique server name here.
name = "nostr-rs-relay"
# Description
description = "A newly created nostr-rs-relay.\n\nCustomize this with your own info."
# Administrative contact pubkey
#pubkey = "0c2d168a4ae8ca58c9f1ab237b5df682599c6c7ab74307ea8b05684b60405d41"
# Administrative contact URI
#contact = "mailto:contact@example.com"
[database]
# Directory for SQLite files. Defaults to the current directory. Can
# also be specified (and overriden) with the "--db dirname" command
# line option.
data_directory = "."
# Database connection pool settings:
# Minimum number of SQLite reader connections
#min_conn = 4
# Maximum number of SQLite reader connections
#max_conn = 128
[network]
# Bind to this network address
address = "0.0.0.0"
# Listen on this port
port = 8080
[options]
# Reject events that have timestamps greater than this many seconds in
# the future. Defaults to rejecting anything greater than 30 minutes
# from the current time.
reject_future_seconds = 1800
[limits]
# Limit events created per second, averaged over one minute. Must be
# an integer. If not set (or set to 0), defaults to unlimited.
#messages_per_sec = 0
# Limit the maximum size of an EVENT message. Defaults to 128 KB.
# Set to 0 for unlimited.
#max_event_bytes = 131072
# Maximum WebSocket message in bytes. Defaults to 128 KB.
#max_ws_message_bytes = 131072
# Maximum WebSocket frame size in bytes. Defaults to 128 KB.
#max_ws_frame_bytes = 131072
# Broadcast buffer size, in number of events. This prevents slow
# readers from consuming memory. Defaults to 4096.
#broadcast_buffer = 4096
# Event persistence buffer size, in number of events. This provides
# backpressure to senders if writes are slow. Defaults to 16.
#event_persist_buffer = 16

53
reverse-proxy.md Normal file
View File

@@ -0,0 +1,53 @@
# Reverse Proxy Setup Guide
It is recommended to run `nostr-rs-relay` behind a reverse proxy such
as `haproxy` or `nginx` to provide TLS termination. A simple example
of an `haproxy` configuration is documented here.
## Minimal HAProxy Configuration
Assumptions:
* HAProxy version is `2.4.10` or greater (older versions not tested).
* Hostname for the relay is `relay.example.com`.
* Your relay should be available over wss://relay.example.com
* Your (NIP-11) relay info page should be available on https://relay.example.com
* SSL certificate is located in `/etc/certs/example.com.pem`.
* Relay is running on port 8080.
* Limit connections to 400 concurrent.
* HSTS (HTTP Strict Transport Security) is desired.
* Only TLS 1.2 or greater is allowed.
```
global
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
frontend fe_prod
mode http
bind :443 ssl crt /etc/certs/example.com.pem alpn h2,http/1.1
bind :80
http-request set-header X-Forwarded-Proto https if { ssl_fc }
redirect scheme https code 301 if !{ ssl_fc }
acl host_relay hdr(host) -i relay.example.com
use_backend relay if host_relay
# HSTS (1 year)
http-response set-header Strict-Transport-Security max-age=31536000
backend relay
mode http
timeout connect 5s
timeout client 50s
timeout server 50s
timeout tunnel 1h
timeout client-fin 30s
option tcp-check
default-server maxconn 400 check inter 20s fastinter 1s
server relay 127.0.0.1:8080
```
### Notes
You may experience WebSocket connection problems with Firefox if
HTTP/2 is enabled, for older versions of HAProxy (2.3.x). Either
disable HTTP/2 (`h2`), or upgrade HAProxy.

147
src/config.rs Normal file
View File

@@ -0,0 +1,147 @@
use lazy_static::lazy_static;
use log::*;
use serde::{Deserialize, Serialize};
use std::sync::RwLock;
// initialize a singleton default configuration
lazy_static! {
pub static ref SETTINGS: RwLock<Settings> = RwLock::new(Settings::default());
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(unused)]
pub struct Info {
pub relay_url: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub pubkey: Option<String>,
pub contact: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Database {
pub data_directory: String,
pub min_conn: u32,
pub max_conn: u32,
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Network {
pub port: u16,
pub address: String,
}
//
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Options {
pub reject_future_seconds: Option<usize>, // if defined, reject any events with a timestamp more than X seconds in the future
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Retention {
// TODO: implement
pub max_events: Option<usize>, // max events
pub max_bytes: Option<usize>, // max size
pub persist_days: Option<usize>, // oldest message
pub whitelist_addresses: Option<Vec<String>>, // whitelisted addresses (never delete)
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Limits {
pub messages_per_sec: Option<u32>, // Artificially slow down event writing to limit disk consumption (averaged over 1 minute)
pub max_event_bytes: Option<usize>, // Maximum size of an EVENT message
pub max_ws_message_bytes: Option<usize>,
pub max_ws_frame_bytes: Option<usize>,
pub broadcast_buffer: usize, // events to buffer for subscribers (prevents slow readers from consuming memory)
pub event_persist_buffer: usize, // events to buffer for database commits (block senders if database writes are too slow)
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Settings {
pub info: Info,
pub database: Database,
pub network: Network,
pub limits: Limits,
pub retention: Retention,
pub options: Options,
}
impl Settings {
pub fn new() -> Self {
let d = Self::default();
// attempt to construct settings with file
// Self::new_from_default(&d).unwrap_or(d)
let from_file = Self::new_from_default(&d);
match from_file {
Ok(f) => f,
Err(e) => {
warn!("Error reading config file ({:?})", e);
d
}
}
}
fn new_from_default(default: &Settings) -> Result<Self, config::ConfigError> {
let config: config::Config = config::Config::new();
let settings: Settings = config
// use defaults
.with_merged(config::Config::try_from(default).unwrap())?
// override with file contents
.with_merged(config::File::with_name("config"))?
.try_into()?;
// ensure connection pool size is logical
if settings.database.min_conn > settings.database.max_conn {
panic!(
"Database min_conn setting ({}) cannot exceed max_conn ({})",
settings.database.min_conn, settings.database.max_conn
);
}
Ok(settings)
}
}
impl Default for Settings {
fn default() -> Self {
Settings {
info: Info {
relay_url: None,
name: Some("Unnamed nostr-rs-relay".to_owned()),
description: None,
pubkey: None,
contact: None,
},
database: Database {
data_directory: ".".to_owned(),
min_conn: 4,
max_conn: 128,
},
network: Network {
port: 8080,
address: "0.0.0.0".to_owned(),
},
limits: Limits {
messages_per_sec: None,
max_event_bytes: Some(2 << 17), // 128K
max_ws_message_bytes: Some(2 << 17), // 128K
max_ws_frame_bytes: Some(2 << 17), // 128K
broadcast_buffer: 4096,
event_persist_buffer: 16,
},
retention: Retention {
max_events: None, // max events
max_bytes: None, // max size
persist_days: None, // oldest message
whitelist_addresses: None, // whitelisted addresses (never delete)
},
options: Options {
reject_future_seconds: Some(30 * 60), // Reject events 30min in the future or greater
},
}
}
}

View File

@@ -53,7 +53,7 @@ impl ClientConn {
v.push(id);
}
}
return v;
v
}
/// Add a new subscription for this connection.

634
src/db.rs
View File

@@ -1,18 +1,38 @@
//! Event persistence and querying
use crate::config;
use crate::error::Result;
use crate::event::Event;
use crate::subscription::Subscription;
use governor::clock::Clock;
use governor::{Quota, RateLimiter};
use hex;
use log::*;
use rusqlite::params;
use rusqlite::Connection;
use rusqlite::OpenFlags;
//use std::num::NonZeroU32;
use crate::config::SETTINGS;
use r2d2;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::limits::Limit;
use rusqlite::types::ToSql;
use std::path::Path;
use std::thread;
use std::time::Instant;
use tokio::task;
pub type SqlitePool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
/// Database file
const DB_FILE: &str = "nostr.db";
/// Startup DB Pragmas
const STARTUP_SQL: &str = r##"
PRAGMA main.synchronous=NORMAL;
PRAGMA foreign_keys = ON;
pragma mmap_size = 536870912; -- 512MB of mmap
"##;
/// Schema definition
const INIT_SQL: &str = r##"
-- Database settings
@@ -21,8 +41,7 @@ PRAGMA journal_mode=WAL;
PRAGMA main.synchronous=NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA application_id = 1654008667;
PRAGMA user_version = 1;
pragma mmap_size = 536870912; -- 512MB of mmap
PRAGMA user_version = 3;
-- Event Table
CREATE TABLE IF NOT EXISTS event (
@@ -32,6 +51,7 @@ first_seen INTEGER NOT NULL, -- when the event was first seen (not authored!) (s
created_at INTEGER NOT NULL, -- when the event was authored
author BLOB NOT NULL, -- author pubkey
kind INTEGER NOT NULL, -- event kind
hidden INTEGER, -- relevant for queries
content TEXT NOT NULL -- serialized json of event object
);
@@ -41,6 +61,21 @@ CREATE INDEX IF NOT EXISTS created_at_index ON event(created_at);
CREATE INDEX IF NOT EXISTS author_index ON event(author);
CREATE INDEX IF NOT EXISTS kind_index ON event(kind);
-- Tag Table
-- Tag values are stored as either a BLOB (if they come in as a
-- hex-string), or TEXT otherwise.
-- This means that searches need to select the appropriate column.
CREATE TABLE IF NOT EXISTS tag (
id INTEGER PRIMARY KEY,
event_id INTEGER NOT NULL, -- an event ID that contains a tag.
name TEXT, -- the tag name ("p", "e", whatever)
value TEXT, -- the tag value, if not hex.
value_hex BLOB, -- the tag value, if it can be interpreted as a hex string.
FOREIGN KEY(event_id) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tag_val_index ON tag(value);
CREATE INDEX IF NOT EXISTS tag_val_hex_index ON tag(value_hex);
-- Event References Table
CREATE TABLE IF NOT EXISTS event_ref (
id INTEGER PRIMARY KEY,
@@ -64,37 +99,189 @@ FOREIGN KEY(event_id) REFERENCES event(id) ON UPDATE RESTRICT ON DELETE CASCADE
CREATE INDEX IF NOT EXISTS pubkey_ref_index ON pubkey_ref(referenced_pubkey);
"##;
pub fn build_read_pool() -> SqlitePool {
info!("Build a connection pool");
let config = config::SETTINGS.read().unwrap();
let db_dir = &config.database.data_directory;
let full_path = Path::new(db_dir).join(DB_FILE);
let manager = SqliteConnectionManager::file(&full_path)
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_init(|c| c.execute_batch(STARTUP_SQL));
let pool: SqlitePool = r2d2::Pool::builder()
.test_on_check_out(true) // no noticeable performance hit
.min_idle(Some(config.database.min_conn))
.max_size(config.database.max_conn)
.build(manager)
.unwrap();
return pool;
}
/// Upgrade DB to latest version, and execute pragma settings
pub fn upgrade_db(conn: &mut Connection) -> Result<()> {
// check the version.
let mut curr_version = db_version(conn)?;
info!("DB version = {:?}", curr_version);
debug!(
"SQLite max query parameters: {}",
conn.limit(Limit::SQLITE_LIMIT_VARIABLE_NUMBER)
);
debug!(
"SQLite max table/blob/text length: {} MB",
(conn.limit(Limit::SQLITE_LIMIT_LENGTH) as f64 / (1024 * 1024) as f64).floor()
);
debug!(
"SQLite max SQL length: {} MB",
(conn.limit(Limit::SQLITE_LIMIT_SQL_LENGTH) as f64 / (1024 * 1024) as f64).floor()
);
// initialize from scratch
if curr_version == 0 {
match conn.execute_batch(INIT_SQL) {
Ok(()) => {
info!("database pragma/schema initialized to v3, and ready");
//curr_version = 3;
}
Err(err) => {
error!("update failed: {}", err);
panic!("database could not be initialized");
}
}
}
if curr_version == 1 {
// only change is adding a hidden column to events.
let upgrade_sql = r##"
ALTER TABLE event ADD hidden INTEGER;
UPDATE event SET hidden=FALSE;
PRAGMA user_version = 2;
"##;
match conn.execute_batch(upgrade_sql) {
Ok(()) => {
info!("database schema upgraded v1 -> v2");
curr_version = 2;
}
Err(err) => {
error!("update failed: {}", err);
panic!("database could not be upgraded");
}
}
}
if curr_version == 2 {
// this version lacks the tag column
debug!("database schema needs update from 2->3");
let upgrade_sql = r##"
CREATE TABLE IF NOT EXISTS tag (
id INTEGER PRIMARY KEY,
event_id INTEGER NOT NULL, -- an event ID that contains a tag.
name TEXT, -- the tag name ("p", "e", whatever)
value TEXT, -- the tag value, if not hex.
value_hex BLOB, -- the tag value, if it can be interpreted as a hex string.
FOREIGN KEY(event_id) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tag_val_index ON tag(value);
CREATE INDEX IF NOT EXISTS tag_val_hex_index ON tag(value_hex);
PRAGMA user_version = 3;
"##;
// TODO: load existing refs into tag table
match conn.execute_batch(upgrade_sql) {
Ok(()) => {
info!("database schema upgraded v2 -> v3");
//curr_version = 3;
}
Err(err) => {
error!("update failed: {}", err);
panic!("database could not be upgraded");
}
}
info!("Starting transaction");
// iterate over every event/pubkey tag
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare("select event_id, \"e\", lower(hex(referenced_event)) from event_ref union select event_id, \"p\", lower(hex(referenced_pubkey)) from pubkey_ref;")?;
let mut tag_rows = stmt.query([])?;
while let Some(row) = tag_rows.next()? {
// we want to capture the event_id that had the tag, the tag name, and the tag hex value.
let event_id: u64 = row.get(0)?;
let tag_name: String = row.get(1)?;
let tag_value: String = row.get(2)?;
// this will leave behind p/e tags that were non-hex, but they are invalid anyways.
if is_hex(&tag_value) {
tx.execute(
"INSERT INTO tag (event_id, name, value_hex) VALUES (?1, ?2, ?3);",
params![event_id, tag_name, hex::decode(&tag_value).ok()],
)?;
}
}
}
tx.commit()?;
info!("Upgrade complete");
} else if curr_version == 3 {
debug!("Database version was already current");
} else if curr_version > 3 {
panic!("Database version is newer than supported by this executable");
}
// Setup PRAGMA
conn.execute_batch(STARTUP_SQL)?;
info!("Finished pragma");
Ok(())
}
/// Spawn a database writer that persists events to the SQLite store.
pub async fn db_writer(
mut event_rx: tokio::sync::mpsc::Receiver<Event>,
bcast_tx: tokio::sync::broadcast::Sender<Event>,
mut shutdown: tokio::sync::broadcast::Receiver<()>,
) -> tokio::task::JoinHandle<Result<()>> {
task::spawn_blocking(move || {
// get database configuration settings
let config = SETTINGS.read().unwrap();
let db_dir = &config.database.data_directory;
let full_path = Path::new(db_dir).join(DB_FILE);
// create a connection
let mut conn = Connection::open_with_flags(
Path::new(DB_FILE),
&full_path,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
)?;
info!("opened database for writing");
// TODO: determine if we need to execute the init script.
// TODO: check database app id / version before proceeding.
match conn.execute_batch(INIT_SQL) {
Ok(()) => info!("database pragma/schema initialized and ready"),
Err(err) => error!("update failed: {}", err),
info!("opened database {:?} for writing", full_path);
upgrade_db(&mut conn)?;
// get rate limit settings
let rps_setting = config.limits.messages_per_sec;
let mut most_recent_rate_limit = Instant::now();
let mut lim_opt = None;
let clock = governor::clock::QuantaClock::default();
if let Some(rps) = rps_setting {
if rps > 0 {
info!("Enabling rate limits for event creation ({}/sec)", rps);
let quota = core::num::NonZeroU32::new(rps * 60).unwrap();
lim_opt = Some(RateLimiter::direct(Quota::per_minute(quota)));
}
}
loop {
if shutdown.try_recv().is_ok() {
info!("shutting down database writer");
break;
}
// call blocking read on channel
let next_event = event_rx.blocking_recv();
// if the channel has closed, we will never get work
if next_event.is_none() {
break;
}
let mut event_write = false;
let event = next_event.unwrap();
let start = Instant::now();
match write_event(&mut conn, &event) {
Ok(updated) => {
if updated == 0 {
info!("nothing inserted (dupe?)");
debug!("ignoring duplicate event");
} else {
info!("persisted event: {}", event.get_event_id_prefix());
info!(
"persisted event: {} in {:?}",
event.get_event_id_prefix(),
start.elapsed()
);
event_write = true;
// send this out to all clients
bcast_tx.send(event.clone()).ok();
}
@@ -103,6 +290,28 @@ pub async fn db_writer(
warn!("event insert failed: {}", err);
}
}
// use rate limit, if defined, and if an event was actually written.
if event_write {
if let Some(ref lim) = lim_opt {
if let Err(n) = lim.check() {
let wait_for = n.wait_time_from(clock.now());
// check if we have recently logged rate
// limits, but print out a message only once
// per second.
if most_recent_rate_limit.elapsed().as_secs() > 1 {
warn!(
"rate limit reached for event creation (sleep for {:?})",
wait_for
);
// reset last rate limit message
most_recent_rate_limit = Instant::now();
}
// block event writes, allowing them to queue up
thread::sleep(wait_for);
continue;
}
}
}
}
conn.close().ok();
info!("database connection closed");
@@ -110,6 +319,12 @@ pub async fn db_writer(
})
}
pub fn db_version(conn: &mut Connection) -> Result<usize> {
let query = "PRAGMA user_version;";
let curr_version = conn.query_row(query, [], |row| row.get(0))?;
Ok(curr_version)
}
/// Persist an event to the database.
pub fn write_event(conn: &mut Connection, e: &Event) -> Result<usize> {
// start transaction
@@ -120,7 +335,7 @@ pub fn write_event(conn: &mut Connection, e: &Event) -> Result<usize> {
let event_str = serde_json::to_string(&e).ok();
// ignore if the event hash is a duplicate.
let ins_count = tx.execute(
"INSERT OR IGNORE INTO event (event_hash, created_at, kind, author, content, first_seen) VALUES (?1, ?2, ?3, ?4, ?5, strftime('%s','now'));",
"INSERT OR IGNORE INTO event (event_hash, created_at, kind, author, content, first_seen, hidden) VALUES (?1, ?2, ?3, ?4, ?5, strftime('%s','now'), FALSE);",
params![id_blob, e.created_at, e.kind, pubkey_blob, event_str]
)?;
if ins_count == 0 {
@@ -130,24 +345,46 @@ pub fn write_event(conn: &mut Connection, e: &Event) -> Result<usize> {
}
// remember primary key of the event most recently inserted.
let ev_id = tx.last_insert_rowid();
// add all event tags into the event_ref table
let etags = e.get_event_tags();
if !etags.is_empty() {
for etag in etags.iter() {
tx.execute(
"INSERT OR IGNORE INTO event_ref (event_id, referenced_event) VALUES (?1, ?2)",
params![ev_id, hex::decode(&etag).ok()],
)?;
// add all tags to the tag table
for tag in e.tags.iter() {
// ensure we have 2 values.
if tag.len() >= 2 {
let tagname = &tag[0];
let tagval = &tag[1];
// if tagvalue is hex;
if is_hex(tagval) {
tx.execute(
"INSERT OR IGNORE INTO tag (event_id, name, value_hex) VALUES (?1, ?2, ?3)",
params![ev_id, &tagname, hex::decode(&tagval).ok()],
)?;
} else {
tx.execute(
"INSERT OR IGNORE INTO tag (event_id, name, value) VALUES (?1, ?2, ?3)",
params![ev_id, &tagname, &tagval],
)?;
}
}
}
// add all event tags into the pubkey_ref table
let ptags = e.get_pubkey_tags();
if !ptags.is_empty() {
for ptag in ptags.iter() {
tx.execute(
"INSERT OR IGNORE INTO pubkey_ref (event_id, referenced_pubkey) VALUES (?1, ?2)",
params![ev_id, hex::decode(&ptag).ok()],
)?;
// if this event is for a metadata update, hide every other kind=0
// event from the same author that was issued earlier than this.
if e.kind == 0 {
let update_count = tx.execute(
"UPDATE event SET hidden=TRUE WHERE id!=? AND kind=0 AND author=? AND created_at <= ? and hidden!=TRUE",
params![ev_id, hex::decode(&e.pubkey).ok(), e.created_at],
)?;
if update_count > 0 {
info!("hid {} older metadata events", update_count);
}
}
// if this event is for a contact update, hide every other kind=3
// event from the same author that was issued earlier than this.
if e.kind == 3 {
let update_count = tx.execute(
"UPDATE event SET hidden=TRUE WHERE id!=? AND kind=3 AND author=? AND created_at <= ? and hidden!=TRUE",
params![ev_id, hex::decode(&e.pubkey).ok(), e.created_at],
)?;
if update_count > 0 {
info!("hid {} older contact events", update_count);
}
}
tx.commit()?;
@@ -168,60 +405,194 @@ fn is_hex(s: &str) -> bool {
s.chars().all(|x| char::is_ascii_hexdigit(&x))
}
/// Create a dynamic SQL query string from a subscription.
fn query_from_sub(sub: &Subscription) -> String {
/// Check if a string contains only f chars
fn is_all_fs(s: &str) -> bool {
s.chars().all(|x| x == 'f' || x == 'F')
}
#[derive(PartialEq, Debug, Clone)]
enum HexSearch {
// when no range is needed, exact 32-byte
Exact(Vec<u8>),
// lower (inclusive) and upper range (exclusive)
Range(Vec<u8>, Vec<u8>),
// lower bound only, upper bound is MAX inclusive
LowerOnly(Vec<u8>),
}
/// Find the next hex sequence greater than the argument.
fn hex_range(s: &str) -> Option<HexSearch> {
// handle special cases
if !is_hex(s) || s.len() > 64 {
return None;
}
if s.len() == 64 {
return Some(HexSearch::Exact(hex::decode(s).ok()?));
}
// if s is odd, add a zero
let mut hash_base = s.to_owned();
let mut odd = hash_base.len() % 2 != 0;
if odd {
// extend the string to make it even
hash_base.push('0');
}
let base = hex::decode(hash_base).ok()?;
// check for all ff's
if is_all_fs(s) {
// there is no higher bound, we only want to search for blobs greater than this.
return Some(HexSearch::LowerOnly(base));
}
// return a range
let mut upper = base.clone();
let mut byte_len = upper.len();
// for odd strings, we made them longer, but we want to increment the upper char (+16).
// we know we can do this without overflowing because we explicitly set the bottom half to 0's.
while byte_len > 0 {
byte_len -= 1;
// check if byte can be incremented, or if we need to carry.
let b = upper[byte_len];
if b == u8::MAX {
// reset and carry
upper[byte_len] = 0;
} else if odd {
// check if first char in this byte is NOT 'f'
if b < 240 {
upper[byte_len] = b + 16; // bump up the first character in this byte
// increment done, stop iterating through the vec
break;
} else {
// if it is 'f', reset the byte to 0 and do a carry
// reset and carry
upper[byte_len] = 0;
}
// done with odd logic, so don't repeat this
odd = false;
} else {
// bump up the first character in this byte
upper[byte_len] = b + 1;
// increment done, stop iterating
break;
}
}
Some(HexSearch::Range(base, upper))
}
fn repeat_vars(count: usize) -> String {
if count == 0 {
return "".to_owned();
}
let mut s = "?,".repeat(count);
// Remove trailing comma
s.pop();
s
}
/// Create a dynamic SQL query string and params from a subscription.
fn query_from_sub(sub: &Subscription) -> (String, Vec<Box<dyn ToSql>>) {
// build a dynamic SQL query. all user-input is either an integer
// (sqli-safe), or a string that is filtered to only contain
// hexadecimal characters.
// hexadecimal characters. Strings that require escaping (tag
// names/values) use parameters.
let mut query =
"SELECT DISTINCT(e.content) FROM event e LEFT JOIN event_ref er ON e.id=er.event_id LEFT JOIN pubkey_ref pr ON e.id=pr.event_id "
.to_owned();
"SELECT DISTINCT(e.content) FROM event e LEFT JOIN tag t ON e.id=t.event_id ".to_owned();
// parameters
let mut params: Vec<Box<dyn ToSql>> = vec![];
// for every filter in the subscription, generate a where clause
let mut filter_clauses: Vec<String> = Vec::new();
for f in sub.filters.iter() {
// individual filter components
let mut filter_components: Vec<String> = Vec::new();
// Query for "authors"
if f.authors.is_some() {
let authors_escaped: Vec<String> = f
.authors
.as_ref()
.unwrap()
.iter()
.filter(|&x| is_hex(x))
.map(|x| format!("x'{}'", x))
.collect();
let authors_clause = format!("author IN ({})", authors_escaped.join(", "));
// Query for "authors", allowing prefix matches
if let Some(authvec) = &f.authors {
// take each author and convert to a hexsearch
let mut auth_searches: Vec<String> = vec![];
for auth in authvec {
match hex_range(auth) {
Some(HexSearch::Exact(ex)) => {
info!("Exact match for author");
auth_searches.push("author=?".to_owned());
params.push(Box::new(ex));
}
Some(HexSearch::Range(lower, upper)) => {
auth_searches.push("(author>? AND author<?)".to_owned());
params.push(Box::new(lower));
params.push(Box::new(upper));
}
Some(HexSearch::LowerOnly(lower)) => {
// info!("{:?} => lower; {:?} ", auth, hex::encode(lower));
auth_searches.push("author>?".to_owned());
params.push(Box::new(lower));
}
None => {
info!("Could not parse hex range from {:?}", auth);
}
}
}
let authors_clause = format!("({})", auth_searches.join(" OR "));
filter_components.push(authors_clause);
}
// Query for Kind
if f.kind.is_some() {
if let Some(ks) = &f.kinds {
// kind is number, no escaping needed
let kind_clause = format!("kind = {}", f.kind.unwrap());
let str_kinds: Vec<String> = ks.iter().map(|x| x.to_string()).collect();
let kind_clause = format!("kind IN ({})", str_kinds.join(", "));
filter_components.push(kind_clause);
}
// Query for event
if f.id.is_some() {
let id_str = f.id.as_ref().unwrap();
if is_hex(id_str) {
let id_clause = format!("event_hash = x'{}'", id_str);
filter_components.push(id_clause);
// Query for event, allowing prefix matches
if let Some(idvec) = &f.ids {
// take each author and convert to a hexsearch
let mut id_searches: Vec<String> = vec![];
for id in idvec {
match hex_range(id) {
Some(HexSearch::Exact(ex)) => {
id_searches.push("event_hash=?".to_owned());
params.push(Box::new(ex));
}
Some(HexSearch::Range(lower, upper)) => {
id_searches.push("(event_hash>? AND event_hash<?)".to_owned());
params.push(Box::new(lower));
params.push(Box::new(upper));
}
Some(HexSearch::LowerOnly(lower)) => {
id_searches.push("event_hash>?".to_owned());
params.push(Box::new(lower));
}
None => {
info!("Could not parse hex range from {:?}", id);
}
}
}
let id_clause = format!("({})", id_searches.join(" OR "));
filter_components.push(id_clause);
}
// Query for referenced event
if f.event.is_some() {
let ev_str = f.event.as_ref().unwrap();
if is_hex(ev_str) {
let ev_clause = format!("referenced_event = x'{}'", ev_str);
filter_components.push(ev_clause);
}
}
// Query for referenced pet name pubkey
if f.pubkey.is_some() {
let pet_str = f.pubkey.as_ref().unwrap();
if is_hex(pet_str) {
let pet_clause = format!("referenced_pubkey = x'{}'", pet_str);
filter_components.push(pet_clause);
// Query for tags
if let Some(map) = &f.tags {
for (key, val) in map.iter() {
let mut str_vals: Vec<Box<dyn ToSql>> = vec![];
let mut blob_vals: Vec<Box<dyn ToSql>> = vec![];
for v in val {
if is_hex(v) {
if let Ok(h) = hex::decode(&v) {
blob_vals.push(Box::new(h));
}
} else {
str_vals.push(Box::new(v.to_owned()));
}
}
// create clauses with "?" params for each tag value being searched
let str_clause = format!("value IN ({})", repeat_vars(str_vals.len()));
let blob_clause = format!("value_hex IN ({})", repeat_vars(blob_vals.len()));
let tag_clause = format!("(name=? AND ({} OR {}))", str_clause, blob_clause);
// add the tag name as the first parameter
params.push(Box::new(key.to_owned()));
// add all tag values that are plain strings as params
params.append(&mut str_vals);
// add all tag values that are blobs as params
params.append(&mut blob_vals);
filter_components.push(tag_clause);
}
}
// Query for timestamp
@@ -229,25 +600,34 @@ fn query_from_sub(sub: &Subscription) -> String {
let created_clause = format!("created_at > {}", f.since.unwrap());
filter_components.push(created_clause);
}
// Query for timestamp
if f.until.is_some() {
let until_clause = format!("created_at < {}", f.until.unwrap());
filter_components.push(until_clause);
}
// combine all clauses, and add to filter_clauses
if !filter_components.is_empty() {
let mut fc = "( ".to_owned();
fc.push_str(&filter_components.join(" AND "));
fc.push_str(" )");
filter_clauses.push(fc);
} else {
// if there are no filter clauses, we should return everything
filter_clauses.push(" 1=1 ".to_owned());
}
}
// never display hidden events
query.push_str(" WHERE hidden!=TRUE ");
// combine all filters with OR clauses, if any exist
if !filter_clauses.is_empty() {
query.push_str(" WHERE ");
query.push_str(" AND (");
query.push_str(&filter_clauses.join(" OR "));
query.push_str(") ");
}
// add order clause
query.push_str(" ORDER BY created_at ASC");
debug!("query string: {}", query);
query
(query, params)
}
/// Perform a database query using a subscription.
@@ -258,28 +638,27 @@ fn query_from_sub(sub: &Subscription) -> String {
/// query is immediately aborted.
pub async fn db_query(
sub: Subscription,
conn: r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>,
query_tx: tokio::sync::mpsc::Sender<QueryResult>,
mut abandon_query_rx: tokio::sync::oneshot::Receiver<()>,
) {
task::spawn_blocking(move || {
let conn =
Connection::open_with_flags(Path::new(DB_FILE), OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
debug!("opened database for reading");
debug!("going to query for: {:?}", sub);
let mut row_count: usize = 0;
let start = Instant::now();
// generate SQL query
let q = query_from_sub(&sub);
// execute the query
let mut stmt = conn.prepare(&q).unwrap();
let mut event_rows = stmt.query([]).unwrap();
while let Some(row) = event_rows.next().unwrap() {
let (q, p) = query_from_sub(&sub);
// execute the query. Don't cache, since queries vary so much.
let mut stmt = conn.prepare(&q)?;
let mut event_rows = stmt.query(rusqlite::params_from_iter(p))?;
while let Some(row) = event_rows.next()? {
// check if this is still active (we could do this every N rows)
if abandon_query_rx.try_recv().is_ok() {
debug!("query aborted");
return;
return Ok(());
}
// TODO: check before unwrapping
let event_json = row.get(0).unwrap();
row_count += 1;
let event_json = row.get(0)?;
query_tx
.blocking_send(QueryResult {
sub_id: sub.get_id(),
@@ -287,6 +666,93 @@ pub async fn db_query(
})
.ok();
}
debug!("query completed");
debug!(
"query completed ({} rows) in {:?}",
row_count,
start.elapsed()
);
let ok: Result<()> = Ok(());
ok
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_range_exact() -> Result<()> {
let hex = "abcdef00abcdef00abcdef00abcdef00abcdef00abcdef00abcdef00abcdef00";
let r = hex_range(hex);
assert_eq!(
r,
Some(HexSearch::Exact(hex::decode(hex).expect("invalid hex")))
);
Ok(())
}
#[test]
fn hex_full_range() -> Result<()> {
//let hex = "abcdef00abcdef00abcdef00abcdef00abcdef00abcdef00abcdef00abcdef00";
let hex = "aaaa";
let hex_upper = "aaab";
let r = hex_range(hex);
assert_eq!(
r,
Some(HexSearch::Range(
hex::decode(hex).expect("invalid hex"),
hex::decode(hex_upper).expect("invalid hex")
))
);
Ok(())
}
#[test]
fn hex_full_range_odd() -> Result<()> {
let r = hex_range("abc");
assert_eq!(
r,
Some(HexSearch::Range(
hex::decode("abc0").expect("invalid hex"),
hex::decode("abd0").expect("invalid hex")
))
);
Ok(())
}
#[test]
fn hex_full_range_odd_end_f() -> Result<()> {
let r = hex_range("abf");
assert_eq!(
r,
Some(HexSearch::Range(
hex::decode("abf0").expect("invalid hex"),
hex::decode("ac00").expect("invalid hex")
))
);
Ok(())
}
#[test]
fn hex_no_upper() -> Result<()> {
let r = hex_range("ffff");
assert_eq!(
r,
Some(HexSearch::LowerOnly(
hex::decode("ffff").expect("invalid hex")
))
);
Ok(())
}
#[test]
fn hex_no_upper_odd() -> Result<()> {
let r = hex_range("fff");
assert_eq!(
r,
Some(HexSearch::LowerOnly(
hex::decode("fff0").expect("invalid hex")
))
);
Ok(())
}
}

View File

@@ -21,6 +21,8 @@ pub enum Error {
CloseParseFailed,
#[error("Event validation failed")]
EventInvalid,
#[error("Event too large")]
EventMaxLengthError(usize),
#[error("Subscription identifier max length exceeded")]
SubIdMaxLengthError,
#[error("Maximum concurrent subscription count reached")]
@@ -34,6 +36,12 @@ pub enum Error {
CommandUnknownError,
#[error("SQL error")]
SqlError(rusqlite::Error),
#[error("Config error")]
ConfigError(config::ConfigError),
#[error("Data directory does not exist")]
DatabaseDirError,
#[error("Custom Error : {0}")]
CustomError(String),
}
impl From<rusqlite::Error> for Error {
@@ -56,3 +64,10 @@ impl From<WsError> for Error {
Error::WebsocketError(r)
}
}
impl From<config::ConfigError> for Error {
/// Wrap Config error
fn from(r: config::ConfigError) -> Self {
Error::ConfigError(r)
}
}

View File

@@ -1,13 +1,22 @@
//! Event parsing and validation
use crate::config;
use crate::error::Error::*;
use crate::error::Result;
use bitcoin_hashes::{sha256, Hash};
use log::info;
use secp256k1::{schnorrsig, Secp256k1};
use lazy_static::lazy_static;
use log::*;
use secp256k1::{schnorr, Secp256k1, VerifyOnly, XOnlyPublicKey};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::value::Value;
use serde_json::Number;
use std::collections::HashMap;
use std::collections::HashSet;
use std::str::FromStr;
use std::time::SystemTime;
lazy_static! {
pub static ref SECP: Secp256k1<VerifyOnly> = Secp256k1::verification_only();
}
/// Event command in network format
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
@@ -28,6 +37,9 @@ pub struct Event {
pub(crate) tags: Vec<Vec<String>>,
pub(crate) content: String,
pub(crate) sig: String,
// Optimization for tag search, built on demand
#[serde(skip)]
pub(crate) tagidx: Option<HashMap<String, HashSet<String>>>,
}
/// Simple tag type for array of array of strings.
@@ -49,14 +61,48 @@ impl From<EventCmd> for Result<Event> {
if ec.cmd != "EVENT" {
Err(CommandUnknownError)
} else if ec.event.is_valid() {
Ok(ec.event)
let mut e = ec.event;
e.build_index();
Ok(e)
} else {
Err(EventInvalid)
}
}
}
/// Seconds since 1970
fn unix_time() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|x| x.as_secs())
.unwrap_or(0)
}
impl Event {
/// Build an event tag index
fn build_index(&mut self) {
// if there are no tags; just leave the index as None
if self.tags.is_empty() {
return;
}
// otherwise, build an index
let mut idx: HashMap<String, HashSet<String>> = HashMap::new();
// iterate over tags that have at least 2 elements
for t in self.tags.iter().filter(|x| x.len() > 1) {
let tagname = t.get(0).unwrap();
let tagval = t.get(1).unwrap();
// ensure a vector exists for this tag
if !idx.contains_key(tagname) {
idx.insert(tagname.clone(), HashSet::new());
}
// get the tag vec and insert entry
let tidx = idx.get_mut(tagname).expect("could not get tag vector");
tidx.insert(tagval.clone());
}
// save the tag structure
self.tagidx = Some(idx);
}
/// Create a short event identifier, suitable for logging.
pub fn get_event_id_prefix(&self) -> String {
self.id.chars().take(8).collect()
@@ -64,6 +110,22 @@ impl Event {
/// Check if this event has a valid signature.
fn is_valid(&self) -> bool {
// TODO: return a Result with a reason for invalid events
// don't bother to validate an event with a timestamp in the distant future.
let config = config::SETTINGS.read().unwrap();
let max_future_sec = config.options.reject_future_seconds;
if let Some(allowable_future) = max_future_sec {
let curr_time = unix_time();
// calculate difference, plus how far future we allow
if curr_time + (allowable_future as u64) < self.created_at {
let delta = self.created_at - curr_time;
debug!(
"Event is too far in the future ({} seconds), rejecting",
delta
);
return false;
}
}
// validation is performed by:
// * parsing JSON string into event fields
// * create an array:
@@ -83,12 +145,16 @@ impl Event {
return false;
}
// * validate the message digest (sig) using the pubkey & computed sha256 message hash.
let secp = Secp256k1::new();
let sig = schnorrsig::Signature::from_str(&self.sig).unwrap();
let message = secp256k1::Message::from(digest);
let pubkey = schnorrsig::PublicKey::from_str(&self.pubkey).unwrap();
let verify = secp.schnorrsig_verify(&sig, &message, &pubkey);
matches!(verify, Ok(()))
let sig = schnorr::Signature::from_str(&self.sig).unwrap();
if let Ok(msg) = secp256k1::Message::from_slice(digest.as_ref()) {
let pubkey = XOnlyPublicKey::from_str(&self.pubkey).unwrap();
let verify = SECP.verify_schnorr(&sig, &msg, &pubkey);
matches!(verify, Ok(()))
} else {
warn!("Error converting digest to secp256k1 message");
false
}
}
/// Convert event to canonical representation for signing.
@@ -128,36 +194,18 @@ impl Event {
serde_json::Value::Array(tags)
}
/// Get a list of event tags.
pub fn get_event_tags(&self) -> Vec<&str> {
let mut etags = vec![];
for t in self.tags.iter() {
if t.len() >= 2 && t.get(0).unwrap() == "e" {
etags.push(&t.get(1).unwrap()[..]);
}
/// Determine if the given tag and value set intersect with tags in this event.
pub fn generic_tag_val_intersect(&self, tagname: &str, check: &HashSet<String>) -> bool {
match &self.tagidx {
Some(idx) => match idx.get(tagname) {
Some(valset) => {
let common = valset.intersection(check);
common.count() > 0
}
None => false,
},
None => false,
}
etags
}
/// Get a list of pubkey/petname tags.
pub fn get_pubkey_tags(&self) -> Vec<&str> {
let mut ptags = vec![];
for t in self.tags.iter() {
if t.len() >= 2 && t.get(0).unwrap() == "p" {
ptags.push(&t.get(1).unwrap()[..]);
}
}
ptags
}
/// Check if a given event is referenced in an event tag.
pub fn event_tag_match(&self, eventid: &str) -> bool {
self.get_event_tags().contains(&eventid)
}
/// Check if a given event is referenced in an event tag.
pub fn pubkey_tag_match(&self, pubkey: &str) -> bool {
self.get_pubkey_tags().contains(&pubkey)
}
}
@@ -173,6 +221,7 @@ mod tests {
tags: vec![],
content: "".to_owned(),
sig: "0".to_owned(),
tagidx: None,
}
}
@@ -195,7 +244,8 @@ mod tests {
#[test]
fn empty_event_tag_match() -> Result<()> {
let event = simple_event();
assert!(!event.event_tag_match("foo"));
assert!(!event
.generic_tag_val_intersect("e", &HashSet::from(["foo".to_owned(), "bar".to_owned()])));
Ok(())
}
@@ -203,7 +253,14 @@ mod tests {
fn single_event_tag_match() -> Result<()> {
let mut event = simple_event();
event.tags = vec![vec!["e".to_owned(), "foo".to_owned()]];
assert!(event.event_tag_match("foo"));
event.build_index();
assert_eq!(
event.generic_tag_val_intersect(
"e",
&HashSet::from(["foo".to_owned(), "bar".to_owned()])
),
true
);
Ok(())
}
@@ -247,6 +304,7 @@ mod tests {
tags: vec![],
content: "this is a test".to_owned(),
sig: "abcde".to_owned(),
tagidx: None,
};
let c = e.to_canonical();
let expected = Some(r#"[0,"012345",501234,1,[],"this is a test"]"#.to_owned());
@@ -270,6 +328,7 @@ mod tests {
],
content: "this is a test".to_owned(),
sig: "abcde".to_owned(),
tagidx: None,
};
let c = e.to_canonical();
let expected_json = r###"[0,"012345",501234,1,[["#e","aoeu"],["#p","aaaa","ws://example.com"]],"this is a test"]"###;

42
src/info.rs Normal file
View File

@@ -0,0 +1,42 @@
use crate::config;
/// Relay Info
use serde::{Deserialize, Serialize};
const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct RelayInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supported_nips: Option<Vec<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
/// Convert an Info configuration into public Relay Info
impl From<config::Info> for RelayInfo {
fn from(i: config::Info) -> Self {
RelayInfo {
id: i.relay_url,
name: i.name,
description: i.description,
pubkey: i.pubkey,
contact: i.contact,
supported_nips: Some(vec![1, 2, 11]),
software: Some("https://git.sr.ht/~gheartsfield/nostr-rs-relay".to_owned()),
version: CARGO_PKG_VERSION.map(|x| x.to_owned()),
}
}
}

View File

@@ -1,7 +1,10 @@
pub mod close;
pub mod config;
pub mod conn;
pub mod db;
pub mod error;
pub mod event;
pub mod info;
pub mod protostream;
pub mod subscription;
pub mod tags;

View File

@@ -1,31 +1,185 @@
//! Server process
use futures::SinkExt;
use futures::StreamExt;
use hyper::header::ACCEPT;
use hyper::service::{make_service_fn, service_fn};
use hyper::upgrade::Upgraded;
use hyper::{
header, server::conn::AddrStream, upgrade, Body, Request, Response, Server, StatusCode,
};
use log::*;
use nostr_rs_relay::close::Close;
use nostr_rs_relay::config;
use nostr_rs_relay::conn;
use nostr_rs_relay::db;
use nostr_rs_relay::error::{Error, Result};
use nostr_rs_relay::event::Event;
use nostr_rs_relay::info::RelayInfo;
use nostr_rs_relay::protostream;
use nostr_rs_relay::protostream::NostrMessage::*;
use nostr_rs_relay::protostream::NostrResponse::*;
use std::collections::HashMap;
use std::convert::Infallible;
use std::env;
use tokio::net::{TcpListener, TcpStream};
use std::net::SocketAddr;
use std::path::Path;
use tokio::runtime::Builder;
use tokio::sync::broadcast;
use tokio::sync::broadcast::{Receiver, Sender};
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_tungstenite::WebSocketStream;
use tungstenite::handshake;
use tungstenite::protocol::WebSocketConfig;
/// Return a requested DB name from command line arguments.
fn db_from_args(args: Vec<String>) -> Option<String> {
if args.len() == 3 && args.get(1) == Some(&"--db".to_owned()) {
return args.get(2).map(|x| x.to_owned());
}
None
}
/// Handle arbitrary HTTP requests, including for WebSocket upgrades.
async fn handle_web_request(
mut request: Request<Body>,
pool: db::SqlitePool,
remote_addr: SocketAddr,
broadcast: Sender<Event>,
event_tx: tokio::sync::mpsc::Sender<Event>,
shutdown: Receiver<()>,
) -> Result<Response<Body>, Infallible> {
match (
request.uri().path(),
request.headers().contains_key(header::UPGRADE),
) {
// Request for / as websocket
("/", true) => {
debug!("websocket with upgrade request");
//assume request is a handshake, so create the handshake response
let response = match handshake::server::create_response_with_body(&request, || {
Body::empty()
}) {
Ok(response) => {
//in case the handshake response creation succeeds,
//spawn a task to handle the websocket connection
tokio::spawn(async move {
//using the hyper feature of upgrading a connection
match upgrade::on(&mut request).await {
//if successfully upgraded
Ok(upgraded) => {
// set WebSocket configuration options
let mut config = WebSocketConfig::default();
{
let settings = config::SETTINGS.read().unwrap();
config.max_message_size = settings.limits.max_ws_message_bytes;
config.max_frame_size = settings.limits.max_ws_frame_bytes;
}
//create a websocket stream from the upgraded object
let ws_stream = WebSocketStream::from_raw_socket(
//pass the upgraded object
//as the base layer stream of the Websocket
upgraded,
tokio_tungstenite::tungstenite::protocol::Role::Server,
Some(config),
)
.await;
tokio::spawn(nostr_server(
pool, ws_stream, broadcast, event_tx, shutdown,
));
}
Err(e) => println!(
"error when trying to upgrade connection \
from address {} to websocket connection. \
Error is: {}",
remote_addr, e
),
}
});
//return the response to the handshake request
response
}
Err(error) => {
warn!("websocket response failed");
let mut res =
Response::new(Body::from(format!("Failed to create websocket: {}", error)));
*res.status_mut() = StatusCode::BAD_REQUEST;
return Ok(res);
}
};
Ok::<_, Infallible>(response)
}
// Request for Relay info
("/", false) => {
// handle request at root with no upgrade header
// Check if this is a nostr server info request
let accept_header = &request.headers().get(ACCEPT);
// check if application/nostr+json is included
if let Some(media_types) = accept_header {
if let Ok(mt_str) = media_types.to_str() {
if mt_str.contains("application/nostr+json") {
let config = config::SETTINGS.read().unwrap();
// build a relay info response
debug!("Responding to server info request");
let rinfo = RelayInfo::from(config.info.clone());
let b = Body::from(serde_json::to_string_pretty(&rinfo).unwrap());
return Ok(Response::builder()
.status(200)
.header("Content-Type", "application/nostr+json")
.body(b)
.unwrap());
}
}
}
Ok(Response::new(Body::from(
"Please use a Nostr client to connect.",
)))
}
(_, _) => {
//handle any other url
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Nothing here."))
.unwrap())
}
}
}
async fn shutdown_signal() {
// Wait for the CTRL+C signal
tokio::signal::ctrl_c()
.await
.expect("failed to install CTRL+C signal handler");
}
/// Start running a Nostr relay server.
fn main() -> Result<(), Error> {
// setup logger and environment
// setup logger
let _ = env_logger::try_init();
let addr = env::args()
.nth(1)
.unwrap_or_else(|| "0.0.0.0:8080".to_string());
// get database directory from args
let args: Vec<String> = env::args().collect();
let db_dir: Option<String> = db_from_args(args);
{
let mut settings = config::SETTINGS.write().unwrap();
// replace default settings with those read from config.toml
let mut c = config::Settings::new();
// update with database location
if let Some(db) = db_dir {
c.database.data_directory = db;
}
*settings = c;
}
let config = config::SETTINGS.read().unwrap();
// do some config validation.
if !Path::new(&config.database.data_directory).is_dir() {
error!("Database directory does not exist");
return Err(Error::DatabaseDirError);
}
debug!("config: {:?}", config);
let addr = format!("{}:{}", config.network.address.trim(), config.network.port);
let socket_addr = addr.parse().expect("listening address not valid");
// configure tokio runtime
let rt = Builder::new_multi_thread()
.enable_all()
@@ -34,51 +188,63 @@ fn main() -> Result<(), Error> {
.unwrap();
// start tokio
rt.block_on(async {
let listener = TcpListener::bind(&addr).await.expect("Failed to bind");
info!("listening on: {}", addr);
let settings = config::SETTINGS.read().unwrap();
info!("listening on: {}", socket_addr);
// build a connection pool for sqlite connections
let pool = db::build_read_pool();
// all client-submitted valid events are broadcast to every
// other client on this channel. This should be large enough
// to accomodate slower readers (messages are dropped if
// clients can not keep up).
let (bcast_tx, _) = broadcast::channel::<Event>(4096);
let (bcast_tx, _) = broadcast::channel::<Event>(settings.limits.broadcast_buffer);
// validated events that need to be persisted are sent to the
// database on via this channel.
let (event_tx, event_rx) = mpsc::channel::<Event>(16);
// start the database writer thread. Give it a channel for
// writing events, and for publishing events that have been
// written (to all connected clients).
db::db_writer(event_rx, bcast_tx.clone()).await;
let (event_tx, event_rx) = mpsc::channel::<Event>(settings.limits.event_persist_buffer);
// establish a channel for letting all threads now about a
// requested server shutdown.
let (invoke_shutdown, _) = broadcast::channel::<()>(1);
let ctrl_c_shutdown = invoke_shutdown.clone();
// listen for ctrl-c interruupts
// // listen for ctrl-c interruupts
tokio::spawn(async move {
tokio::signal::ctrl_c().await.unwrap();
info!("shutting down due to SIGINT");
ctrl_c_shutdown.send(()).ok();
});
// track unique client connection count
let mut client_accept_count: usize = 0;
let mut stop_listening = invoke_shutdown.subscribe();
// handle new client connection requests, or SIGINT signals.
loop {
tokio::select! {
_ = stop_listening.recv() => {
break;
}
Ok((stream, _)) = listener.accept() => {
client_accept_count += 1;
info!("creating new connection for client #{}",client_accept_count);
tokio::spawn(nostr_server(
stream,
bcast_tx.clone(),
event_tx.clone(),
invoke_shutdown.subscribe(),
));
}
// start the database writer thread. Give it a channel for
// writing events, and for publishing events that have been
// written (to all connected clients).
db::db_writer(event_rx, bcast_tx.clone(), invoke_shutdown.subscribe()).await;
info!("db writer created");
// A `Service` is needed for every connection, so this
// creates one from our `handle_request` function.
let make_svc = make_service_fn(|conn: &AddrStream| {
let svc_pool = pool.clone();
let remote_addr = conn.remote_addr();
let bcast = bcast_tx.clone();
let event = event_tx.clone();
let stop = invoke_shutdown.clone();
async move {
// service_fn converts our function into a `Service`
Ok::<_, Infallible>(service_fn(move |request: Request<Body>| {
handle_web_request(
request,
svc_pool.clone(),
remote_addr,
bcast.clone(),
event.clone(),
stop.subscribe(),
)
}))
}
});
let server = Server::bind(&socket_addr)
.serve(make_svc)
.with_graceful_shutdown(shutdown_signal());
// run hyper
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
// our code
});
Ok(())
}
@@ -86,7 +252,8 @@ fn main() -> Result<(), Error> {
/// Handle new client connections. This runs through an event loop
/// for all client communication.
async fn nostr_server(
stream: TcpStream,
pool: db::SqlitePool,
ws_stream: WebSocketStream<Upgraded>,
broadcast: Sender<Event>,
event_tx: tokio::sync::mpsc::Sender<Event>,
mut shutdown: Receiver<()>,
@@ -94,8 +261,8 @@ async fn nostr_server(
// get a broadcast channel for clients to communicate on
let mut bcast_rx = broadcast.subscribe();
// upgrade the TCP connection to WebSocket
let conn = tokio_tungstenite::accept_async(stream).await;
let ws_stream = conn.expect("websocket handshake error");
//let conn = tokio_tungstenite::accept_async_with_config(stream, Some(config)).await;
//let ws_stream = conn.expect("websocket handshake error");
// wrap websocket into a stream & sink of Nostr protocol messages
let mut nostr_stream = protostream::wrap_ws_in_nostr(ws_stream);
// Track internal client state
@@ -177,7 +344,9 @@ async fn nostr_server(
Ok(()) => {
running_queries.insert(s.id.to_owned(), abandon_query_tx);
// start a database query
db::db_query(s, query_tx.clone(), abandon_query_rx).await;
// show pool stats
debug!("DB pool stats: {:?}", pool.state());
db::db_query(s, pool.get().expect("could not get connection"), query_tx.clone(), abandon_query_rx).await;
},
Err(e) => {
info!("Subscription error: {}", e);
@@ -215,6 +384,10 @@ async fn nostr_server(
debug!("got connection close/error, disconnecting client: {}",cid);
break;
}
Some(Err(Error::EventMaxLengthError(s))) => {
info!("client {} sent event larger ({} bytes) than max size", cid, s);
nostr_stream.send(NoticeRes("event exceeded max size".to_owned())).await.ok();
},
Some(Err(e)) => {
info!("got non-fatal error from client: {}, error: {:?}", cid, e);
},

View File

@@ -1,5 +1,6 @@
//! Nostr protocol layered over WebSocket
use crate::close::CloseCmd;
use crate::config;
use crate::error::{Error, Result};
use crate::event::EventCmd;
use crate::subscription::Subscription;
@@ -8,9 +9,9 @@ use futures::sink::Sink;
use futures::stream::Stream;
use futures::task::Context;
use futures::task::Poll;
use hyper::upgrade::Upgraded;
use log::*;
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio_tungstenite::WebSocketStream;
use tungstenite::error::Error as WsError;
use tungstenite::protocol::Message;
@@ -39,11 +40,11 @@ pub enum NostrResponse {
/// A Nostr protocol stream is layered on top of a Websocket stream.
pub struct NostrStream {
ws_stream: WebSocketStream<TcpStream>,
ws_stream: WebSocketStream<Upgraded>,
}
/// Given a websocket, return a protocol stream wrapper.
pub fn wrap_ws_in_nostr(ws: WebSocketStream<TcpStream>) -> NostrStream {
pub fn wrap_ws_in_nostr(ws: WebSocketStream<Upgraded>) -> NostrStream {
NostrStream { ws_stream: ws }
}
@@ -51,13 +52,26 @@ pub fn wrap_ws_in_nostr(ws: WebSocketStream<TcpStream>) -> NostrStream {
impl Stream for NostrStream {
type Item = Result<NostrMessage>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
// get the configuration
/// Convert Message to NostrMessage
fn convert(msg: String) -> Result<NostrMessage> {
let config = config::SETTINGS.read().unwrap();
let parsed_res: Result<NostrMessage> = serde_json::from_str(&msg).map_err(|e| e.into());
match parsed_res {
Ok(m) => Ok(m),
Ok(m) => {
if let NostrMessage::EventMsg(_) = m {
if let Some(max_size) = config.limits.max_event_bytes {
// check length, ensure that some max size is set.
if msg.len() > max_size && max_size > 0 {
return Err(Error::EventMaxLengthError(msg.len()));
}
}
}
Ok(m)
}
Err(e) => {
debug!("proto parse error: {:?}", e);
debug!("parse error on message: {}", msg.trim());
Err(Error::ProtoParseError)
}
}
@@ -67,8 +81,14 @@ impl Stream for NostrStream {
Poll::Ready(None) => Poll::Ready(None),
Poll::Ready(Some(v)) => match v {
Ok(Message::Text(vs)) => Poll::Ready(Some(convert(vs))),
Ok(Message::Ping(_x)) => {
debug!("client ping");
//Pin::new(&mut self.ws_stream).start_send(Message::Pong(x));
//info!("sent pong");
Poll::Pending
}
Ok(Message::Binary(_)) => Poll::Ready(Some(Err(Error::ProtoParseError))),
Ok(Message::Pong(_)) | Ok(Message::Ping(_)) => Poll::Pending,
Ok(Message::Pong(_)) => Poll::Pending,
Ok(Message::Close(_)) => Poll::Ready(None),
Err(WsError::AlreadyClosed) | Err(WsError::ConnectionClosed) => Poll::Ready(None),
Err(_) => Poll::Ready(Some(Err(Error::ConnError))),

View File

@@ -1,7 +1,11 @@
//! Subscription and filter parsing
use crate::error::Result;
use crate::event::Event;
use serde::de::Unexpected;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::collections::HashSet;
/// Subscription identifier and set of request filters
#[derive(Serialize, PartialEq, Debug, Clone)]
@@ -15,23 +19,76 @@ pub struct Subscription {
/// Corresponds to client-provided subscription request elements. Any
/// element can be present if it should be used in filtering, or
/// absent ([`None`]) if it should be ignored.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(deny_unknown_fields)]
#[derive(Serialize, PartialEq, Debug, Clone)]
pub struct ReqFilter {
/// Event hash
pub id: Option<String>,
/// Event kind
pub kind: Option<u64>,
/// Referenced event hash
#[serde(rename = "#e")]
pub event: Option<String>,
/// Referenced public key for a petname
#[serde(rename = "#p")]
pub pubkey: Option<String>,
/// Event hashes
pub ids: Option<Vec<String>>,
/// Event kinds
pub kinds: Option<Vec<u64>>,
/// Events published after this time
pub since: Option<u64>,
/// Events published before this time
pub until: Option<u64>,
/// List of author public keys
pub authors: Option<Vec<String>>,
/// Set of tags
#[serde(skip)]
pub tags: Option<HashMap<String, HashSet<String>>>,
}
impl<'de> Deserialize<'de> for ReqFilter {
fn deserialize<D>(deserializer: D) -> Result<ReqFilter, D::Error>
where
D: Deserializer<'de>,
{
let received: Value = Deserialize::deserialize(deserializer)?;
let filter = received.as_object().ok_or_else(|| {
serde::de::Error::invalid_type(
Unexpected::Other("reqfilter is not an object"),
&"a json object",
)
})?;
let mut rf = ReqFilter {
ids: None,
kinds: None,
since: None,
until: None,
authors: None,
tags: None,
};
let mut ts = None;
// iterate through each key, and assign values that exist
for (key, val) in filter.into_iter() {
// ids
if key == "ids" {
rf.ids = Deserialize::deserialize(val).ok();
} else if key == "kinds" {
rf.kinds = Deserialize::deserialize(val).ok();
} else if key == "since" {
rf.since = Deserialize::deserialize(val).ok();
} else if key == "until" {
rf.until = Deserialize::deserialize(val).ok();
} else if key == "authors" {
rf.authors = Deserialize::deserialize(val).ok();
} else if key.starts_with('#') && key.len() > 1 && val.is_array() {
// remove the prefix
let tagname = &key[1..];
if ts.is_none() {
// Initialize the tag if necessary
ts = Some(HashMap::new());
}
if let Some(m) = ts.as_mut() {
let tag_vals: Option<Vec<String>> = Deserialize::deserialize(val).ok();
if let Some(v) = tag_vals {
let hs = HashSet::from_iter(v.into_iter());
m.insert(tagname.to_owned(), hs);
}
};
}
}
rf.tags = ts;
Ok(rf)
}
}
impl<'de> Deserialize<'de> for Subscription {
@@ -41,7 +98,7 @@ impl<'de> Deserialize<'de> for Subscription {
where
D: Deserializer<'de>,
{
let mut v: serde_json::Value = Deserialize::deserialize(deserializer)?;
let mut v: Value = Deserialize::deserialize(deserializer)?;
// this shoud be a 3-or-more element array.
// verify the first element is a String, REQ
// get the subscription from the second element.
@@ -76,6 +133,7 @@ impl<'de> Deserialize<'de> for Subscription {
for fv in i {
let f: ReqFilter = serde_json::from_value(fv.take())
.map_err(|_| serde::de::Error::custom("could not parse filter"))?;
// create indexes
filters.push(f);
}
Ok(Subscription {
@@ -102,46 +160,63 @@ impl Subscription {
}
}
impl ReqFilter {
/// Check for a match within the authors list.
// TODO: Ambiguity; what if the array is empty? Should we
// consider that the same as null?
fn authors_match(&self, event: &Event) -> bool {
self.authors
.as_ref()
.map(|vs| vs.contains(&event.pubkey.to_owned()))
.unwrap_or(true)
fn prefix_match(prefixes: &[String], target: &str) -> bool {
for prefix in prefixes {
if target.starts_with(prefix) {
return true;
}
}
/// Check if this filter either matches, or does not care about the event tags.
fn event_match(&self, event: &Event) -> bool {
self.event
// none matched
false
}
impl ReqFilter {
fn ids_match(&self, event: &Event) -> bool {
self.ids
.as_ref()
.map(|t| event.event_tag_match(t))
.map(|vs| prefix_match(vs, &event.id))
.unwrap_or(true)
}
/// Check if this filter either matches, or does not care about
/// the pubkey/petname tags.
fn pubkey_match(&self, event: &Event) -> bool {
self.pubkey
fn authors_match(&self, event: &Event) -> bool {
self.authors
.as_ref()
.map(|t| event.pubkey_tag_match(t))
.map(|vs| prefix_match(vs, &event.pubkey))
.unwrap_or(true)
}
fn tag_match(&self, event: &Event) -> bool {
// get the hashset from the filter.
if let Some(map) = &self.tags {
for (key, val) in map.iter() {
let tag_match = event.generic_tag_val_intersect(key, val);
// if there is no match for this tag, the match fails.
if !tag_match {
return false;
}
// if there was a match, we move on to the next one.
}
}
// if the tag map is empty, the match succeeds (there was no filter)
true
}
/// Check if this filter either matches, or does not care about the kind.
fn kind_match(&self, kind: u64) -> bool {
self.kind.map(|v| v == kind).unwrap_or(true)
self.kinds
.as_ref()
.map(|ks| ks.contains(&kind))
.unwrap_or(true)
}
/// Determine if all populated fields in this filter match the provided event.
pub fn interested_in_event(&self, event: &Event) -> bool {
self.id.as_ref().map(|v| v == &event.id).unwrap_or(true)
// self.id.as_ref().map(|v| v == &event.id).unwrap_or(true)
self.ids_match(event)
&& self.since.map(|t| event.created_at > t).unwrap_or(true)
&& self.kind_match(event.kind)
&& self.authors_match(event)
&& self.pubkey_match(event)
&& self.event_match(event)
&& self.tag_match(event)
}
}
@@ -172,27 +247,66 @@ mod tests {
}
#[test]
fn invalid_filter() {
// unrecognized field in filter
let raw_json = "[\"REQ\",\"some-id\",{\"foo\": 3}]";
assert!(serde_json::from_str::<Subscription>(raw_json).is_err());
fn legacy_filter() {
// legacy field in filter
let raw_json = "[\"REQ\",\"some-id\",{\"kind\": 3}]";
assert!(serde_json::from_str::<Subscription>(raw_json).is_ok());
}
#[test]
fn author_filter() -> Result<()> {
let raw_json = "[\"REQ\",\"some-id\",{\"author\": \"test-author-id\"}]";
let raw_json = r#"["REQ","some-id",{"authors": ["test-author-id"]}]"#;
let s: Subscription = serde_json::from_str(raw_json)?;
assert_eq!(s.id, "some-id");
assert_eq!(s.filters.len(), 1);
let first_filter = s.filters.get(0).unwrap();
assert_eq!(first_filter.author, Some("test-author-id".to_owned()));
assert_eq!(
first_filter.authors,
Some(vec!("test-author-id".to_owned()))
);
Ok(())
}
#[test]
fn interest_author_prefix_match() -> Result<()> {
// subscription with a filter for ID
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"authors": ["abc"]}]"#)?;
let e = Event {
id: "foo".to_owned(),
pubkey: "abcd".to_owned(),
created_at: 0,
kind: 0,
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert!(s.interested_in_event(&e));
Ok(())
}
#[test]
fn interest_id_prefix_match() -> Result<()> {
// subscription with a filter for ID
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"ids": ["abc"]}]"#)?;
let e = Event {
id: "abcd".to_owned(),
pubkey: "".to_owned(),
created_at: 0,
kind: 0,
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert!(s.interested_in_event(&e));
Ok(())
}
#[test]
fn interest_id_nomatch() -> Result<()> {
// subscription with a filter for ID
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"id":"abc"}]"#)?;
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"ids": ["xyz"]}]"#)?;
let e = Event {
id: "abcde".to_owned(),
pubkey: "".to_owned(),
@@ -201,15 +315,17 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), false);
assert!(!s.interested_in_event(&e));
Ok(())
}
#[test]
fn interest_time_and_id() -> Result<()> {
// subscription with a filter for ID and time
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"id":"abc", "since": 1000}]"#)?;
let s: Subscription =
serde_json::from_str(r#"["REQ","xyz",{"ids": ["abc"], "since": 1000}]"#)?;
let e = Event {
id: "abc".to_owned(),
pubkey: "".to_owned(),
@@ -218,8 +334,9 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), false);
assert!(!s.interested_in_event(&e));
Ok(())
}
@@ -235,8 +352,9 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), true);
assert!(s.interested_in_event(&e));
Ok(())
}
@@ -252,8 +370,9 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), true);
assert!(s.interested_in_event(&e));
Ok(())
}
@@ -269,8 +388,9 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), true);
assert!(s.interested_in_event(&e));
Ok(())
}
#[test]
@@ -286,8 +406,9 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), true);
assert!(s.interested_in_event(&e));
Ok(())
}
@@ -303,8 +424,9 @@ mod tests {
tags: Vec::new(),
content: "".to_owned(),
sig: "".to_owned(),
tagidx: None,
};
assert_eq!(s.interested_in_event(&e), false);
assert!(!s.interested_in_event(&e));
Ok(())
}
}

315
src/tags.rs Normal file
View File

@@ -0,0 +1,315 @@
//! Tags used in events to link to another event or a pubkey
//!
//! Reference specification NIP01: https://github.com/fiatjaf/nostr/blob/master/nips/01.md#events-and-signatures
//!
use bitcoin_hashes::hex::ToHex;
use bitcoin_hashes::sha256;
use secp256k1::XOnlyPublicKey;
use std::str::FromStr;
use serde::de::Unexpected;
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serializer};
use serde_json::Value;
use crate::error::Error;
type EventId = sha256::Hash;
#[derive(Debug, PartialEq)]
struct EventTag {
event_id: EventId,
recommended_url: Option<String>,
}
#[derive(Debug, PartialEq)]
struct PubkeyTag {
pubkey: XOnlyPublicKey,
recommended_url: Option<String>,
}
// Tag structure representing two possible types of tags
#[derive(Debug, PartialEq)]
enum Tag {
Event(EventTag),
Pubkey(PubkeyTag),
}
// Custom json serialization into protocol network format
// Event tag : ["e", "<32 byte event-id>", "optional<url>"]
// Pubkey tag : ["p", "<32 byte Xonly Pubkey>", "optional<url>"]
impl serde::Serialize for Tag {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Event(event_tag) => {
let mut seq = serializer.serialize_seq(None)?;
seq.serialize_element("e")?;
seq.serialize_element(&event_tag.event_id.to_hex())?;
if let Some(url) = &event_tag.recommended_url {
seq.serialize_element(url)?;
} else {
}
seq.end()
}
Self::Pubkey(pubkey_tag) => {
let mut seq = serializer.serialize_seq(None)?;
seq.serialize_element("p")?;
seq.serialize_element(&pubkey_tag.pubkey.to_hex())?;
if let Some(url) = &pubkey_tag.recommended_url {
seq.serialize_element(url)?;
} else {
}
seq.end()
}
}
}
}
// Custom json deserialization from protocol network format
impl<'de> serde::Deserialize<'de> for Tag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
// Receive incoming data in a json object
let received: Value = Deserialize::deserialize(deserializer)?;
// Check received data is a json array
let values = received.as_array().ok_or_else(|| {
serde::de::Error::invalid_type(Unexpected::Other("tag json object"), &"json array")
})?;
// Check json array contains only string
let values = values
.iter()
.map(|value| {
value.as_str().ok_or_else(|| {
serde::de::Error::invalid_type(
Unexpected::Other("tag json data"),
&"json string",
)
})
})
.collect::<Result<Vec<_>, _>>()?;
// Check length is not more tha 3
if values.len() > 3 {
Err(serde::de::Error::invalid_length(
values.len(),
&"tag length is 2 or 3",
))
} else {
// Parse the json array into appropriate types
match values[0] {
// This denotes an event type tag
"e" => {
let event_id = EventId::from_str(values[1])
.map_err(|e| serde::de::Error::custom(e.to_string()))?;
let recomended_url = if values.len() == 3 {
Some(values[2].into())
} else {
None
};
Ok(Tag::Event(EventTag {
event_id,
recommended_url: recomended_url,
}))
}
// This denotes a pubkey type tag
"p" => {
let pubkey = XOnlyPublicKey::from_str(values[1])
.map_err(|e| serde::de::Error::custom(e.to_string()))?;
let recomended_url = if values.len() == 3 {
Some(values[2].into())
} else {
None
};
Ok(Tag::Pubkey(PubkeyTag {
pubkey,
recommended_url: recomended_url,
}))
}
// Any other tag type is currently not supported
_ => Err(serde::de::Error::invalid_value(
Unexpected::Other("tag type flag"),
&"'e' or 'p'",
)),
}
}
}
}
#[allow(dead_code)]
// Some api (not public currently) to use Tags
impl Tag {
fn new_event_tag(event_id: EventId, recomended_url: Option<String>) -> Self {
Self::Event(EventTag {
event_id,
recommended_url: recomended_url,
})
}
fn new_pubkey_tag(pubkey: XOnlyPublicKey, recomended_url: Option<String>) -> Self {
Self::Pubkey(PubkeyTag {
pubkey,
recommended_url: recomended_url,
})
}
fn get_recomended_url(&self) -> Option<String> {
match self {
Self::Event(EventTag {
event_id: _,
recommended_url,
}) => recommended_url.clone(),
Self::Pubkey(PubkeyTag {
pubkey: _,
recommended_url,
}) => recommended_url.clone(),
}
}
fn get_event_id(&self) -> Result<EventId, Error> {
match self {
Self::Event(EventTag {
event_id,
recommended_url: _,
}) => Ok(*event_id),
_ => Err(Error::CustomError("Expected event tag".to_string())),
}
}
fn get_pubkey(&self) -> Result<XOnlyPublicKey, Error> {
match self {
Self::Pubkey(PubkeyTag {
pubkey,
recommended_url: _,
}) => Ok(*pubkey),
_ => Err(Error::CustomError("Expected pubkey tag".to_string())),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use bitcoin_hashes::Hash;
#[test]
fn serde_roundtrip() {
let pubkey = XOnlyPublicKey::from_str(
"18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
)
.unwrap();
let url = "wss://rsslay.fiatjaf.com";
static HASH_BYTES: [u8; 32] = [
0xef, 0x53, 0x7f, 0x25, 0xc8, 0x95, 0xbf, 0xa7, 0x82, 0x52, 0x65, 0x29, 0xa9, 0xb6,
0x3d, 0x97, 0xaa, 0x63, 0x15, 0x64, 0xd5, 0xd7, 0x89, 0xc2, 0xb7, 0x65, 0x44, 0x8c,
0x86, 0x35, 0xfb, 0x6c,
];
let event_id = EventId::from_slice(&HASH_BYTES).expect("right number of bytes");
let event_tag = Tag::new_event_tag(event_id, Some(url.to_string()));
let pubkey_tag = Tag::new_pubkey_tag(pubkey, Some(url.to_string()));
let ser_event_tag = serde_json::to_string(&event_tag).unwrap();
let ser_pubkey_tag = serde_json::to_string(&pubkey_tag).unwrap();
let deser_event_tag: Tag = serde_json::from_str(&ser_event_tag).unwrap();
let deser_pubkey_tag: Tag = serde_json::from_str(&ser_pubkey_tag).unwrap();
assert_eq!(deser_event_tag, event_tag);
assert_eq!(deser_pubkey_tag, pubkey_tag);
}
#[test]
fn invalid_pubkey() {
let test_string = r#"["p","845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166","wss://rsslay.fiatjaf.com"]"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"secp: malformed public key".to_string()
);
}
#[test]
fn invalid_datatype() {
let test_string = r#"["p", 188457, "wss://rsslay.fiatjaf.com"]"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"invalid type: tag json data, expected json string".to_string()
);
}
#[test]
fn invalid_tagbyte() {
let test_string = r#"["q", "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", "wss://rsslay.fiatjaf.com"]"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"invalid value: tag type flag, expected 'e' or 'p'".to_string()
);
}
#[test]
fn invalid_event_id() {
let test_string = r#"["e","ef537f25c895bfa782526529a9b63d97aa631564d5d78c8635fb6c","wss://rsslay.fiatjaf.com"]"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"bad hex string length 54 (expected 64)".to_string()
);
}
#[test]
fn invalid_url_type() {
let test_string = r#"["e","ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c", 123456788]"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"invalid type: tag json data, expected json string".to_string()
);
}
#[test]
fn invalid_length() {
let test_string = r#"["e","ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c","wss://rsslay.fiatjaf.com", "Random extra data"]"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"invalid length 4, expected tag length is 2 or 3".to_string()
);
}
#[test]
fn invalid_json_type() {
let test_string = r#"{"type": "e","event": "ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c"}"#;
let tag: Result<Tag, _> = serde_json::from_str(test_string);
assert_eq!(
tag.err().expect("expect error").to_string(),
"invalid type: tag json object, expected json array".to_string()
);
}
#[test]
fn evenrt_tag_missing_url() {
let test_string =
r#"["e","ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c"]"#;
let tag: Tag = serde_json::from_str(test_string).unwrap();
assert!(tag.get_recomended_url().is_none());
}
#[test]
fn pubkey_tag_missing_url() {
let test_string =
r#"["p","18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166"]"#;
let tag: Tag = serde_json::from_str(test_string).unwrap();
assert!(tag.get_recomended_url().is_none());
}
}