Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
048199e30b | ||
|
|
414e83f696 | ||
|
|
225c8f762e | ||
|
|
887fc28ab2 | ||
|
|
294d3b99c3 | ||
|
|
53990672ae | ||
|
|
9c1b21cbfe | ||
|
|
2f63417646 | ||
|
|
3b25160852 | ||
|
|
34ad549cde | ||
|
|
f8b1fe5035 | ||
|
|
f2001dc34a | ||
|
|
b593001229 | ||
|
|
5913b9f87a | ||
|
|
77f35f9f43 | ||
|
|
9e06cc9482 | ||
|
|
e66fa4ac42 | ||
|
|
99e117f620 | ||
|
|
8250e00f05 | ||
|
|
c9f87ec563 | ||
|
|
ceaa01e8b4 | ||
|
|
bc68cd0c74 | ||
|
|
97589006fa | ||
|
|
e31d0729f2 | ||
|
|
89d96e7ccd | ||
|
|
7056aae227 | ||
|
|
753df47443 | ||
|
|
26a0ce2b32 | ||
|
|
fa66a0265e | ||
|
|
234a8ba0ac | ||
|
|
f679fa0893 | ||
|
|
4cc313fa2d | ||
|
|
6502f7dcd7 | ||
|
|
6ca3e3ffea | ||
|
|
49c668a07c | ||
|
|
98c6fa6f39 | ||
|
|
452bbbb0e5 | ||
|
|
ee0de6f875 | ||
|
|
699489ebaf | ||
|
|
af9da65f71 | ||
|
|
a72eaec3b8 | ||
|
|
f1206e76f2 | ||
|
|
af453548ee | ||
|
|
df251c821c | ||
|
|
2d28a95ff7 | ||
|
|
8c93ef5bc2 | ||
|
|
1c0fc1326d | ||
|
|
179928378e | ||
|
|
c605d75bb4 | ||
|
|
81e4e2b892 | ||
|
|
6f166433b5 | ||
|
|
030b64de62 | ||
|
|
c7eadb1154 | ||
|
|
62dc77369d | ||
|
|
24587435ca | ||
|
|
a3124ccea4 | ||
|
|
4e51e61d16 | ||
|
|
5c8390bbe0 | ||
|
|
da7968efef | ||
|
|
7037555516 | ||
|
|
19ed990c57 | ||
|
|
d78bbfc290 | ||
|
|
2924da88bc | ||
|
|
3024e9fba4 | ||
|
|
d3da4eb009 | ||
|
|
19637d612e | ||
|
|
afc9a0096a | ||
|
|
3d56262386 | ||
|
|
6673fcfd11 | ||
|
|
b5da3fa2b0 | ||
|
|
850957213e | ||
|
|
1aa5a5458d | ||
|
|
620e227699 | ||
|
|
14e59ed278 | ||
|
|
5ad383f257 | ||
|
|
9710ea27aa | ||
|
|
783a6e1042 | ||
|
|
4171a8870e | ||
|
|
8f3891c781 | ||
|
|
415d32299b | ||
|
|
5a19a8876f | ||
|
|
f7f12a7984 | ||
|
|
20ee5a054c | ||
|
|
c60519de23 | ||
|
|
d72e7a57b6 | ||
|
|
6447ddd974 | ||
|
|
079722ddd9 | ||
|
|
3302fb2e81 | ||
|
|
f415295184 | ||
|
|
d730bf0c59 | ||
|
|
2e2e01203b | ||
|
|
100f890284 | ||
|
|
0e288fe678 | ||
|
|
bfc804e18c | ||
|
|
8a8ee5c425 | ||
|
|
55bb6bd440 | ||
|
|
7933abaa48 | ||
|
|
9b959e1012 | ||
|
|
5b6a20dfa6 | ||
|
|
acf6231277 | ||
|
|
49598b2c9e | ||
|
|
54e6e0e5ce | ||
|
|
850064b871 | ||
|
|
b3c7852b19 | ||
|
|
6910b8d945 | ||
|
|
531f6c4624 | ||
|
|
1589268eba | ||
|
|
56c40f2be9 | ||
|
|
cacd1ccb36 | ||
|
|
ee8862be81 | ||
|
|
108938f709 | ||
|
|
e732f918f9 | ||
|
|
ca0f01c94b | ||
|
|
04850506a8 | ||
|
|
d79e0a59f5 | ||
|
|
ee9efab2f7 | ||
|
|
f91b1205ef | ||
|
|
65f62dc4ec | ||
|
|
65969a4121 | ||
|
|
23f47899cd | ||
|
|
8b4c43ae71 | ||
|
|
35ceb7cb64 | ||
|
|
e7d0ab1aca | ||
|
|
92e9a5e639 | ||
|
|
d0c2b242cd |
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
nostr.db
|
||||||
|
|||||||
Generated
+1154
-147
File diff suppressed because it is too large
Load Diff
+28
-21
@@ -1,25 +1,32 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nostr-rs-relay"
|
name = "nostr-rs-relay"
|
||||||
version = "0.1.0"
|
version = "0.5.2"
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = "0.4.14"
|
log = "^0.4"
|
||||||
env_logger = "0.9.0"
|
env_logger = "^0.9"
|
||||||
tokio = { version = "1.14.0", features = ["full"] }
|
tokio = { version = "^1.16", features = ["full"] }
|
||||||
futures = "0.3.18"
|
futures = "^0.3"
|
||||||
futures-util = "0.3.18"
|
futures-util = "^0.3"
|
||||||
tokio-tungstenite = "0.16.0"
|
tokio-tungstenite = "^0.17"
|
||||||
tungstenite = "0.16.0"
|
tungstenite = "^0.17"
|
||||||
thiserror = "1.0.30"
|
thiserror = "^1"
|
||||||
uuid = { version = "0.8", features = ["v4"] }
|
uuid = { version = "^0.8", features = ["v4"] }
|
||||||
|
config = { version = "^0.12", features = ["toml"] }
|
||||||
|
bitcoin_hashes = { version = "^0.10", features = ["serde"] }
|
||||||
bitcoin_hashes = { version = "0.10.0", features = ["serde"] }
|
secp256k1 = {version = "^0.21", features = ["rand", "rand-std", "serde", "bitcoin_hashes"] }
|
||||||
secp256k1 = { version = "0.20.3", features = ["rand", "rand-std", "serde", "bitcoin_hashes"] }
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
serde = { version = "1.0.130", features = ["derive"] }
|
serde_json = {version = "^1.0", features = ["preserve_order"]}
|
||||||
serde_json = "1.0.71"
|
hex = "^0.4"
|
||||||
serde_repr = "0.1.7"
|
rusqlite = { version = "^0.26", features = ["limits"]}
|
||||||
base64 = "0.13.0"
|
r2d2 = "^0.8"
|
||||||
|
r2d2_sqlite = "^0.19"
|
||||||
|
lazy_static = "^1.4"
|
||||||
|
governor = "^0.4"
|
||||||
|
nonzero_ext = "^0.3"
|
||||||
|
hyper = { version="0.14", features=["client", "server","http1","http2","tcp"] }
|
||||||
|
hyper-tls = "^0.5"
|
||||||
|
http = { version = "^0.2" }
|
||||||
|
parse_duration = "^2"
|
||||||
|
rand = "^0.8"
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
FROM rust:1.58.1 as builder
|
||||||
|
|
||||||
|
RUN USER=root cargo new --bin nostr-rs-relay
|
||||||
|
WORKDIR ./nostr-rs-relay
|
||||||
|
COPY ./Cargo.toml ./Cargo.toml
|
||||||
|
COPY ./Cargo.lock ./Cargo.lock
|
||||||
|
RUN cargo build --release
|
||||||
|
RUN rm src/*.rs
|
||||||
|
|
||||||
|
COPY ./src ./src
|
||||||
|
|
||||||
|
RUN rm ./target/release/deps/nostr*relay*
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bullseye-20220125-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 libc6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV TZ=Etc/UTC \
|
||||||
|
APP_USER=appuser
|
||||||
|
|
||||||
|
RUN groupadd $APP_USER \
|
||||||
|
&& useradd -g $APP_USER $APP_USER \
|
||||||
|
&& mkdir -p ${APP} \
|
||||||
|
&& mkdir -p ${APP_DATA}
|
||||||
|
|
||||||
|
COPY --from=builder /nostr-rs-relay/target/release/nostr-rs-relay ${APP}/nostr-rs-relay
|
||||||
|
|
||||||
|
RUN chown -R $APP_USER:$APP_USER ${APP}
|
||||||
|
|
||||||
|
USER $APP_USER
|
||||||
|
WORKDIR ${APP}
|
||||||
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
ENV APP_DATA=${APP_DATA}
|
||||||
|
|
||||||
|
CMD ./nostr-rs-relay --db ${APP_DATA}
|
||||||
@@ -1,14 +1,87 @@
|
|||||||
[nostr-rs-relay](https://git.sr.ht/~gheartsfield/nostr-rs-relay)
|
# [nostr-rs-relay](https://git.sr.ht/~gheartsfield/nostr-rs-relay)
|
||||||
===
|
|
||||||
|
|
||||||
This is a [nostr](https://github.com/fiatjaf/nostr) relay, written in
|
This is a [nostr](https://github.com/fiatjaf/nostr) relay, written in
|
||||||
Rust. Very much a work-in-progress, and a hobby project to learn
|
Rust. It currently supports the entire relay protocol, and has a
|
||||||
Rust, and tokio/async.
|
SQLite persistence layer.
|
||||||
|
|
||||||
The project master repository is available on
|
The project master repository is available on
|
||||||
[sourcehut](https://sr.ht/~gheartsfield/nostr-rs-relay/), and is
|
[sourcehut](https://sr.ht/~gheartsfield/nostr-rs-relay/), and is
|
||||||
mirrored on [GitHub](https://github.com/scsibug/nostr-rs-relay).
|
mirrored on [GitHub](https://github.com/scsibug/nostr-rs-relay).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
NIPs with a relay-specific implementation are listed here.
|
||||||
|
|
||||||
|
- [x] NIP-01: Core event model
|
||||||
|
- [x] NIP-01: Hide old metadata events
|
||||||
|
- [x] NIP-01: Id/Author prefix search (_experimental_)
|
||||||
|
- [x] NIP-02: Hide old contact list events
|
||||||
|
- [ ] NIP-03: OpenTimestamps
|
||||||
|
- [x] NIP-05: Mapping Nostr keys to DNS identifiers
|
||||||
|
- [ ] NIP-09: Event deletion
|
||||||
|
- [x] NIP-11: Relay information document
|
||||||
|
- [x] NIP-12: Generic tag search (_experimental_)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
(7000 in the example below).
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ docker build -t nostr-rs-relay .
|
||||||
|
|
||||||
|
$ 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.
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ noscl publish "hello world"
|
||||||
|
Sent to 'ws://localhost:8090'.
|
||||||
|
Seen it on 'ws://localhost:8090'.
|
||||||
|
$ noscl home
|
||||||
|
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
|
License
|
||||||
---
|
---
|
||||||
This project is MIT licensed.
|
This project is MIT licensed.
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# 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 for subscribers:
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#broadcast_buffer = 16384
|
||||||
|
|
||||||
|
# Event persistence buffer size, in number of events. This provides
|
||||||
|
# backpressure to senders if writes are slow.
|
||||||
|
#event_persist_buffer = 4096
|
||||||
|
|
||||||
|
[authorization]
|
||||||
|
# Pubkey addresses in this array are whitelisted for event publishing.
|
||||||
|
# Only valid events by these authors will be accepted, if the variable
|
||||||
|
# is set.
|
||||||
|
#pubkey_whitelist = [
|
||||||
|
# "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f",
|
||||||
|
# "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072",
|
||||||
|
#]
|
||||||
|
|
||||||
|
[verified_users]
|
||||||
|
# NIP-05 verification of users. Can be "enabled" to require NIP-05
|
||||||
|
# metadata for event authors, "passive" to perform validation but
|
||||||
|
# never block publishing, or "disabled" to do nothing.
|
||||||
|
#mode = "disabled"
|
||||||
|
|
||||||
|
# Domain names that will be prevented from publishing events.
|
||||||
|
#domain_blacklist = ["wellorder.net"]
|
||||||
|
|
||||||
|
# Domain names that are allowed to publish events. If defined, only
|
||||||
|
# events NIP-05 verified authors at these domains are persisted.
|
||||||
|
#domain_whitelist = ["example.com"]
|
||||||
|
|
||||||
|
# Consider an pubkey "verified" if we have a successful validation
|
||||||
|
# from the NIP-05 domain within this amount of time. Note, if the
|
||||||
|
# domain provides a successful response that omits the account,
|
||||||
|
# verification is immediately revoked.
|
||||||
|
#verify_expiration = "1 week"
|
||||||
|
|
||||||
|
# How long to wait between verification attempts for a specific author.
|
||||||
|
#verify_update_frequency = "24 hours"
|
||||||
|
|
||||||
|
# How many consecutive failed checks before we give up on verifying
|
||||||
|
# this author.
|
||||||
|
#max_consecutive_failures = 20
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Author Verification Design Document
|
||||||
|
|
||||||
|
The relay will use NIP-05 DNS-based author verification to limit which
|
||||||
|
authors can publish events to a relay. This document describes how
|
||||||
|
this feature will operate.
|
||||||
|
|
||||||
|
## Considerations
|
||||||
|
|
||||||
|
DNS-based author verification is designed to be deployed in relays that
|
||||||
|
want to prevent spam, so there should be strong protections to prevent
|
||||||
|
unauthorized authors from persisting data. This includes data needed to
|
||||||
|
verify new authors.
|
||||||
|
|
||||||
|
There should be protections in place to ensure the relay cannot be
|
||||||
|
used to spam or flood other webservers. Additionally, there should be
|
||||||
|
protections against server-side request forgery (SSRF).
|
||||||
|
|
||||||
|
## Design Overview
|
||||||
|
|
||||||
|
### Concepts
|
||||||
|
|
||||||
|
All authors are initially "unverified". Unverified authors that submit
|
||||||
|
appropriate `NIP-05` metadata events become "candidates" for
|
||||||
|
verification. A candidate author becomes verified when the relay
|
||||||
|
inspects a kind `0` metadata event for the author with a `nip05` field,
|
||||||
|
and follows the procedure in `NIP-05` to successfully associate the
|
||||||
|
author with an internet identifier.
|
||||||
|
|
||||||
|
The `NIP-05` procedure verifies an author for a fixed period of time,
|
||||||
|
configurable by the relay operator. If this "verification expiration
|
||||||
|
time" (`verify_expiration`) is exceeded without being refreshed, they
|
||||||
|
are once again unverified.
|
||||||
|
|
||||||
|
Verified authors have their status regularly and automatically updated
|
||||||
|
through scheduled polling to their verified domain, this process is
|
||||||
|
"re-verification". It is performed based on the configuration setting
|
||||||
|
`verify_update_frequency`, which defines how long the relay waits
|
||||||
|
between verification attempts (whether the result was success or
|
||||||
|
failure).
|
||||||
|
|
||||||
|
Authors may change their verification data (the internet identifier from
|
||||||
|
`NIP-05`) with a new metadata event, which then requires
|
||||||
|
re-verification. Their old verification remains valid until
|
||||||
|
expiration.
|
||||||
|
|
||||||
|
Performing candidate author verification is a best-effort activity and
|
||||||
|
may be significantly rate-limited to prevent relays being used to
|
||||||
|
attack other hosts. Candidate verification (untrusted authors) should
|
||||||
|
never impact re-verification (trusted authors).
|
||||||
|
|
||||||
|
## Operating Modes
|
||||||
|
|
||||||
|
The relay may operate in one of three modes. "Disabled" performs no
|
||||||
|
validation activities, and will never permit or deny events based on
|
||||||
|
an author's NIP-05 metadata. "Passive" performs NIP-05 validation,
|
||||||
|
but does not permit or deny events based on the validity or presence
|
||||||
|
of NIP-05 metadata. "Enabled" will require current and valid NIP-05
|
||||||
|
metadata for any events to be persisted. "Enabled" mode will
|
||||||
|
additionally consider domain whitelist/blacklist configuration data to
|
||||||
|
restrict which author's events are persisted.
|
||||||
|
|
||||||
|
## Design Details
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
Verification is stored in a dedicated table. This tracks:
|
||||||
|
|
||||||
|
* `nip05` identifier
|
||||||
|
* most recent verification timestamp
|
||||||
|
* most recent verification failure timestamp
|
||||||
|
* reference to the metadata event (used for tracking `created_at` and
|
||||||
|
`pubkey`)
|
||||||
|
|
||||||
|
### Event Handling
|
||||||
|
|
||||||
|
All events are first validated to ensure the signature is valid.
|
||||||
|
|
||||||
|
Incoming events of kind _other_ than metadata (kind `0`) submitted by
|
||||||
|
clients will be evaluated as follows.
|
||||||
|
|
||||||
|
* If the event's author has a current verification, the event is
|
||||||
|
persisted as normal.
|
||||||
|
* If the event's author has either no verification, or the
|
||||||
|
verification is expired, the event is rejected.
|
||||||
|
|
||||||
|
If the event is a metadata event, we handle it differently.
|
||||||
|
|
||||||
|
We first determine the verification status of the event's pubkey.
|
||||||
|
|
||||||
|
* If the event author is unverified, AND the event contains a `nip05`
|
||||||
|
key, we consider this a verification candidate.
|
||||||
|
* If the event author is unverified, AND the event does not contain a
|
||||||
|
`nip05` key, this is not a candidate, and the event is dropped.
|
||||||
|
|
||||||
|
* If the event author is verified, AND the event contains a `nip05`
|
||||||
|
key that is identical to the currently stored value, no special
|
||||||
|
action is needed.
|
||||||
|
* If the event author is verified, AND the event contains a different
|
||||||
|
`nip05` than was previously verified, with a more recent timestamp,
|
||||||
|
we need to re-verify.
|
||||||
|
* If the event author is verified, AND the event is missing a `nip05`
|
||||||
|
key, and the event timestamp is more recent than what was verified,
|
||||||
|
we do nothing. The current verification will be allowed to expire.
|
||||||
|
|
||||||
|
### Candidate Verification
|
||||||
|
|
||||||
|
When a candidate verification is requested, a rate limit will be
|
||||||
|
utilized. If the rate limit is exceeded, new candidate verification
|
||||||
|
requests will be dropped. In practice, this is implemented by a
|
||||||
|
size-limited channel that drops events that exceed a threshold.
|
||||||
|
|
||||||
|
Candidates are never persisted in the database.
|
||||||
|
|
||||||
|
### Re-Verification
|
||||||
|
|
||||||
|
Re-verification is straightforward when there has been no change to
|
||||||
|
the `nip05` key. A new request to the `nip05` domain is performed,
|
||||||
|
and if successful, the verification timestamp is updated to the
|
||||||
|
current time. If the request fails due to a timeout or server error,
|
||||||
|
the failure timestamp is updated instead.
|
||||||
|
|
||||||
|
When the the `nip05` key has changed and this event is more recent, we
|
||||||
|
will create a new verification record, and delete all other records
|
||||||
|
for the same name.
|
||||||
|
|
||||||
|
Regarding creating new records vs. updating: We never update the event
|
||||||
|
reference or `nip05` identifier in a verification record. Every update
|
||||||
|
either reset the last failure or last success timestamp.
|
||||||
|
|
||||||
|
### Determining Verification Status
|
||||||
|
|
||||||
|
In determining if an event is from a verified author, the following
|
||||||
|
procedure should be used:
|
||||||
|
|
||||||
|
Join the verification table with the event table, to provide
|
||||||
|
verification data alongside the event `created_at` and `pubkey`
|
||||||
|
metadata. Find the most recent verification record for the author,
|
||||||
|
based on the `created_at` time.
|
||||||
|
|
||||||
|
Reject the record if the success timestamp is not within our
|
||||||
|
configured expiration time.
|
||||||
|
|
||||||
|
Reject records with disallowed domains, based on any whitelists or
|
||||||
|
blacklists in effect.
|
||||||
|
|
||||||
|
If a result remains, the author is treated as verified.
|
||||||
|
|
||||||
|
This does give a time window for authors transitioning their verified
|
||||||
|
status between domains. There may be a period of time in which there
|
||||||
|
are multiple valid rows in the verification table for a given author.
|
||||||
|
|
||||||
|
### Cleaning Up Inactive Verifications
|
||||||
|
|
||||||
|
After a author verification has expired, we will continue to check for
|
||||||
|
it to become valid again. After a configurable number of attempts, we
|
||||||
|
should simply forget it, and reclaim the space.
|
||||||
|
|
||||||
|
### Addition of Domain Whitelist/Blacklist
|
||||||
|
|
||||||
|
A set of whitelisted or blacklisted domains may be provided. If both
|
||||||
|
are provided, only the whitelist is used. In this context, domains
|
||||||
|
are either "allowed" (present on a whitelist and NOT present on a
|
||||||
|
blacklist), or "denied" (NOT present on a whitelist and present on a
|
||||||
|
blacklist).
|
||||||
|
|
||||||
|
The processes outlined so far are modified in the presence of these
|
||||||
|
options:
|
||||||
|
|
||||||
|
* Only authors with allowed domains can become candidates for
|
||||||
|
verification.
|
||||||
|
* Verification status queries additionally filter out any denied
|
||||||
|
domains.
|
||||||
|
* Re-verification processes only proceed with allowed domains.
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
We have an existing database writer thread, which receives events and
|
||||||
|
attempts to persist them to disk. Once validated and persisted, these
|
||||||
|
events are broadcast to all subscribers.
|
||||||
|
|
||||||
|
When verification is enabled, the writer must check to ensure a valid,
|
||||||
|
unexpired verification record exists for the auther. All metadata
|
||||||
|
events (regardless of verification status) are forwarded to a verifier
|
||||||
|
module. If the verifier determines a new verification record is
|
||||||
|
needed, it is also responsible for persisting and broadcasting the
|
||||||
|
event, just as the database writer would have done.
|
||||||
|
|
||||||
|
## Threat Scenarios
|
||||||
|
|
||||||
|
Some of these mitigations are fully implemented, others are documented
|
||||||
|
simply to demonstrate a mitigation is possible.
|
||||||
|
|
||||||
|
### Domain Spamming
|
||||||
|
|
||||||
|
*Threat*: A author with a high-volume of events creates a metadata event
|
||||||
|
with a bogus domain, causing the relay to generate significant
|
||||||
|
unwanted traffic to a target.
|
||||||
|
|
||||||
|
*Mitigation*: Rate limiting for all candidate verification will limit
|
||||||
|
external requests to a reasonable amount. Currently, this is a simple
|
||||||
|
delay that slows down the HTTP task.
|
||||||
|
|
||||||
|
### Denial of Service for Legitimate Authors
|
||||||
|
|
||||||
|
*Threat*: A author with a high-volume of events creates a metadata event
|
||||||
|
with a domain that is invalid for them, _but which is used by other
|
||||||
|
legitimate authors_. This triggers rate-limiting against the legitimate
|
||||||
|
domain, and blocks authors from updating their own metadata.
|
||||||
|
|
||||||
|
*Mitigation*: Rate limiting should only apply to candidates, so any
|
||||||
|
existing verified authors have priority for re-verification. New
|
||||||
|
authors will be affected, as we can not distinguish between the threat
|
||||||
|
and a legitimate author. _(Unimplemented)_
|
||||||
|
|
||||||
|
### Denial of Service by Consuming Storage
|
||||||
|
|
||||||
|
*Threat*: A author creates a high volume of random metadata events with
|
||||||
|
unique domains, in order to cause us to store large amounts of data
|
||||||
|
for to-be-verified authors.
|
||||||
|
|
||||||
|
*Mitigation*: No data is stored for candidate authors. This makes it
|
||||||
|
harder for new authors to become verified, but is effective at
|
||||||
|
preventing this attack.
|
||||||
|
|
||||||
|
### Metadata Replay for Verified Author
|
||||||
|
|
||||||
|
*Threat*: Attacker replays out-of-date metadata event for a author, to
|
||||||
|
cause a verification to fail.
|
||||||
|
|
||||||
|
*Mitigation*: New metadata events have their signed timestamp compared
|
||||||
|
against the signed timestamp of the event that has most recently
|
||||||
|
verified them. If the metadata event is older, it is discarded.
|
||||||
|
|
||||||
|
### Server-Side Request Forgery via Metadata
|
||||||
|
|
||||||
|
*Threat*: Attacker includes malicious data in the `nip05` event, which
|
||||||
|
is used to generate HTTP requests against potentially internal
|
||||||
|
resources. Either leaking data, or invoking webservices beyond their
|
||||||
|
own privileges.
|
||||||
|
|
||||||
|
*Mitigation*: Consider detecting and dropping when the `nip05` field
|
||||||
|
is an IP address. Allow the relay operator to utilize the `blacklist`
|
||||||
|
or `whitelist` to constrain hosts that will be contacted. Most
|
||||||
|
importantly, the verification process is hardcoded to only make
|
||||||
|
requests to a known url path
|
||||||
|
(`.well-known/nostr.json?name=<LOCAL_NAME>`). The `<LOCAL_NAME>`
|
||||||
|
component is restricted to a basic ASCII subset (preventing additional
|
||||||
|
URL components).
|
||||||
@@ -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.
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
["EVENT","5496183021909538",{"id":"c352327610efbfe08ab49d8452f7827f0d4798d3c28c1143ef5a4c8d531fa1fa","pubkey":"d843184b145ec26c673b77b9eac5321af19195cb48470165d20e56fe7e727cb7","created_at":1612650401,"kind":1,"tags":null,"content":"gdfshbsdfgb","sig":"ebd534a85fc8510dbcf92023ff9012b89e7408b52d347af788fa223f5df83eae6ab5fced5ab2e3c265bfda9cd6f428b0cb4cdcbd1ac5a13a5cc3265df5d7942d"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"1384757da583e6129ce831c3d7afc775a33a090578f888dd0d010328ad047d0c","pubkey":"bbbd9711d357df4f4e498841fd796535c95c8e751fa35355008a911c41265fca","created_at":1612650459,"kind":1,"tags":null,"content":"hello world","sig":"59d0cc47ab566e81f72fe5f430bcfb9b3c688cb0093d1e6daa49201c00d28ecc3651468b7938642869ed98c0f1b262998e49a05a6ed056c0d92b193f4e93bc21"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"e7ade13f425e3c71a7ebd9d5176059f6ce0469f06317e804cb57c35b71e791da","pubkey":"40334685ed272a062c1a3bac85c995c53c69cbfbd0a02f1a134679986fd39af1","created_at":1612651366,"kind":1,"tags":null,"content":"Hello world","sig":"cefb82b57c47e8ed6fda7bf06513d653b2081cc25817f55c8e56005c84390aedc7a7b8464fde46afad890d1d355c63da2c9c4d8b64cccc70df2232f9f4cf86bb"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"dfcdc27e775dc27024689fcdab44cbf7ec5660bcded5beb25f079aa05468fcc9","pubkey":"09488d7a12468dda7839520515c20e6c2a8272c5df8ec0261db2ec65d4420a39","created_at":1612654851,"kind":4,"tags":null,"content":"HOfkGd7fA2vIsbXuZazDrA==?iv=tAfeHJFgxIjuUvxqlxCcLg==","sig":"7176125524d751e810f8523360eb319076e46d97e70928a0bcf3b6bec9efd121f01adbcf196d428a1a7691f5c94df3c4c3478c9dbd3113745bdb96cc2c8a0a49"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"d349109f225a0e1685913a2559a6a9beca0ec0d6865d617e237a320800dd8fc3","pubkey":"09488d7a12468dda7839520515c20e6c2a8272c5df8ec0261db2ec65d4420a39","created_at":1612654966,"kind":4,"tags":null,"content":"esxa2yzo8b/3xR574joX4g==?iv=2LinF8Zd1fs3DCAmt4cLZg==","sig":"927a81962830e6f2d437e1f5d5cf123e0b536ad5492469483373a02e7f3c1ef4367b121a8e58aed062b07c5f634b108a2d67965e24086337b63e395888c9de9b"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"2740f9572b8330565103640e94c608c561150d115f38d1c9230b221222c7ffec","pubkey":"5d6baec9a3dce295e6a2ae16fef2aa1849d2753d047a4abc16f53183d971183d","created_at":1612702779,"kind":1,"tags":null,"content":"Hello world","sig":"37dd4a4beccabb01a9ae4661953807c82d70d92e92495a5647b83670845063505eee8daf0a0a9f845e74f6420911e6a585fe39a94aefb7f2a274a30912178ae3"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a629291da57e02ecfa72ae4dfab772bb31720756fcd51a0247f1753fbf9afd30","pubkey":"7bb57b0f744317c7300dda43a3e64ebf5e5aff1b180619641de1767bad543ca3","created_at":1612706239,"kind":1,"tags":null,"content":"Hello world","sig":"ae540e301af3b4836d0c6e334076a26de7879cf3a31f28d2599e0c9d71e64f30ba12c3675368eaed324136338c6bb3aaf3f7e6fb62f3e041f6f7e5a6726a4234"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"6ec3918617f694b5dd8bc0a3524a6b341e42425d45dd89784dbc79642cdbb6c0","pubkey":"f8e9ba8fb6f401c6f85ec7671f4b6879ff9ae4d867508d00cd42fcaa43e01974","created_at":1612706419,"kind":1,"tags":null,"content":"hello world","sig":"b8e49754c9dd7f6898a28ed2b5d002026a559f119171a22db7095b0c2fee995475d06dda11a9f482b3b6766c375a2b0c2204ba1fe0a58ef312a90db0a68f9def"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"7a84cab2e6e7ced2ea2bf29d07a739a140c3afe2fefbe83a0578ac6cc7691f66","pubkey":"f56bd7d9e38ef70e5355dd1adcfa2d63b4f8041157b75085a61ad201022067f4","created_at":1612706661,"kind":1,"tags":null,"content":"Hello world","sig":"99fab70b7e51e4bf17eacd246235c06e369c2cfeae1afb76a47f5fc0d9f09ea73da7282a8c3ed0da4d38519159fecd2c06d942afa33f9a44eae5a98922d883ed"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"f01d8c4d834a6c7bd21339f61f42bc282c39c4ec20b9165e197c70ef2ec45bb6","pubkey":"f8e9ba8fb6f401c6f85ec7671f4b6879ff9ae4d867508d00cd42fcaa43e01974","created_at":1612706788,"kind":1,"tags":null,"content":"😘Nostr mooon","sig":"795ce321c1b3f795cd89cd56a6e155d459d1acc49f7d99bfb0c5601be03a6ba6e19a78b7701b2161c0e005ba12ec956693e3b04ab5926b4420137f97044bb62d"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"d424b06c3ab880b4456bb892d824a193c37fec5905127c47e0a58c01f20a8805","pubkey":"f8e9ba8fb6f401c6f85ec7671f4b6879ff9ae4d867508d00cd42fcaa43e01974","created_at":1612706836,"kind":4,"tags":null,"content":"ZDN1DW9WVKy9gtYk8guCXw==?iv=Wni55RkHsIiFr2LDKZGnvA==","sig":"d735906490710367234b0dbd0f656e38862f77b3df3a1b02be240d79c252e21be435c82f943f97af5814135655a052d49ba1a60b623231c71e5aa3e1f05576f3"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"3bc86162407605dfcf629fb1568cdd7b8bef311e4a347230965e3701e0efb550","pubkey":"f56bd7d9e38ef70e5355dd1adcfa2d63b4f8041157b75085a61ad201022067f4","created_at":1612706876,"kind":4,"tags":null,"content":"5hRRuFOeVXk6eDBLtd90DQ==?iv=uvxudX52hqSgoNmLoCa7yw==","sig":"4ad25d91b31c022862d905a3d22cf539aeb0d407e514a495d93271beb87dc8b6d52584c9751e0f2282db3e9ef6d3b954b24fec68b99ccc473efe00eb79902a28"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"03f2d548da33ba1d57f0799ecca65024c22921d401c5664105613084d556e1ca","pubkey":"f56bd7d9e38ef70e5355dd1adcfa2d63b4f8041157b75085a61ad201022067f4","created_at":1612706887,"kind":4,"tags":null,"content":"ijb0cRAp01jqCrsdf6x4XA==?iv=rqfq4NZOYH+aP33PX2u/7g==","sig":"08085386e584a6c35110e32def29325ab82012d3a37074d8144976448d1b15e869af632cf505a2ebe4c2ec944ffc232760f7adb1b9c535122ab3b08e64dff548"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"94010c0e91f5cc089eb92dc13d52a5230ec3b2b853139f8a5e4cb22b329824a3","pubkey":"f8e9ba8fb6f401c6f85ec7671f4b6879ff9ae4d867508d00cd42fcaa43e01974","created_at":1612706924,"kind":4,"tags":null,"content":"LARSxV4ftsCYjdZ6AS3x8g==?iv=mhXHc8fblD1SghCpH9pcYQ==","sig":"f5e4c5f4cdfe0f2f42fbb843a254d225b718a1e3461030b721f6fa3b79cdeab27e4066e0736a8b172af7f4ceced66b62d183f215aa8cd05bd07664cc8c833f47"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"6cc318ed826f77fce5ce0b2c64621693cb6d89ae8671703d3bcecbb49fb2d46e","pubkey":"545423ec685844531c29a2947793f9eb030bb33857651f410e7eee09d7af07d8","created_at":1612707587,"kind":1,"tags":null,"content":"Hello world","sig":"7e0bb2a6da5a5964a933e893767240bdc7d40d419296743775a6bf574c558063884c86912cb5ba50f7037bd12e2b6764dfcaa61c1dd0156f017897311fc4ddd5"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"c2b2675d252f5660d5551bc74d619f9f434388d2d170ee64c8e16ae5e802b663","pubkey":"d6cd8c07574eb86ffe5e3d870f7dd5f77f2284e16dfed0ebe1417264d70bd2bc","created_at":1612712970,"kind":1,"tags":null,"content":"poo","sig":"e1dc366fc7f99cbc976f91382a2165bc0e481570a8d04c2745f82aabc2465c1f9bafaccfd8ff20ae250e5abfcec6dd86a2df5c5d2063d089e7e1100d12e10fc7"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"34ec7e92578415fa9b870ab3dbc914f8cd1a69728d92455ef281719a8ba98d45","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612713213,"kind":1,"tags":null,"content":"Hello world!","sig":"facbe66cac0e1761e48ce5e9874a39013d899e359e82822e91aa8b955febcdd555b2fa201f343bea198912a91eb8cd8a1f05ce6bba7c1fd4b070881eada76403"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"e1dacb8a6ca63fc8a144e3d5e5f18892e21cf6b8dfd7eb019d0443cfceba4bab","pubkey":"2b28d10527292d478c4653402b085d831a3b78759e46f7b46de3b7640e2ad667","created_at":1612713276,"kind":1,"tags":null,"content":"Yo","sig":"b1dd07fb7e97b4822de7f2854a603cc04f4e081427193d8495e8628f7994f0dbbadc367680bb5a2fa0f56699e01862fbf8e452f0698bbf66536d732663d32876"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"1f8cd2db4c7e7153f5b7b9e67953980ace79af78e47bba6b2a5e66ab93c749bb","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612713555,"kind":1,"tags":null,"content":"Follow your mates!","sig":"1727f52b4257f1f6fba8a6fe0b6b0180ee404f76fde18427840fa4d513010ba802b4b8fd261209ab196465b3ffe11e8d7ccbbc1aa557d36a4be753b251eb03d1"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a94596ffc55baa20157dac4c09544fe2e925d9cc21cb5765a6744762892bbeab","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612713686,"kind":4,"tags":null,"content":"sidnxTMcUmJWv5esjKgdbQ==?iv=k885PbcWY/1Rs4SOVuVBpw==","sig":"0653438b09f25101bd46a118752adda07fa8eebc89b1aab725ca6c4e09d028d9b6d28e16382b4d0bfb583cbcb142fbe8ff59815f898168b287bc288128eda5a7"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"cbe6c74e1a8337ad1c90a4d2cf4602942070735a35330c6603b92cf406e61a93","pubkey":"2b28d10527292d478c4653402b085d831a3b78759e46f7b46de3b7640e2ad667","created_at":1612713689,"kind":4,"tags":null,"content":"GnBL88R3Q8UyM14hqJYM9w==?iv=uqJo8XCW1kFnK6EV/gZAKg==","sig":"95f6273f6be520af9f84ea1249e57b755f6f4b773708abc5c9aed2f46817e92e04012c34e5d9a0f5604774df4bcb1dec2f32dbc7599fb051fd774352c209b9a1"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"90c8b94d5687efa38bf7ab27d6f6d334ad66a6a6ce8a2a85c1ce1a1fcb624938","pubkey":"2b28d10527292d478c4653402b085d831a3b78759e46f7b46de3b7640e2ad667","created_at":1612713727,"kind":4,"tags":null,"content":"9fFEbgB4zpC+w2ebOU+75Q==?iv=Sr0fAFqjt2bKSrg67ExELQ==","sig":"03a224e416b1cdc8c2690905e7dfe7e44c3470e9fbe49e63717d603717900d4c5fb20c129433e8308cd170ea901a10e29833d33df7c8eb00f67da6eec4b24ee1"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"c3f5afc494bba4f3859fba2d89af388b006eaacdcea2085778a852d84dd91d69","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612714060,"kind":4,"tags":null,"content":"PGRihYEwIGDBepMSwSIzAw==?iv=qsiS+2Tpuvm/XjHzfeSZsA==","sig":"55dc0046403e3369fe1dbd05053ccb74d130ad13bbd49fa07a228bbcb0c2a2168b3eae2176179a7ac5470a6af5b2eb3fd22bc2e4f4f5583f26b9affd6175c7c9"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"7d55fca77dc47d50e4b2a36ebf178de9e4e9108e51324ee5ebef101faeddf295","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612717927,"kind":1,"tags":null,"content":"Hello, Dolu there","sig":"df6ef0dcd4f698436102b65c2712e80b8eb8e0af57ddec958e7dc123b7861efc3e52c81ff29a9bd3491e97b43168f5babbed1da3fd552e04e2df50c62f24e987"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"49e822a04f7172c7cae32c1d02191eee78bb741a743c79988a4bc7c972ae1ee1","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612717967,"kind":4,"tags":null,"content":"lZjRPAfWaP4zHB8SRS93KA==?iv=KSj6xm+7L5QcKazYem+OJw==","sig":"58192c278352bf560fd0875a329c64f9270ebe193246ffc3506782e37c52aed449b073f420e1759bc72c536ce5e01a2b948bfdf9cec46b7ebbce393ac4df1fbd"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"dc6ce72faea7c32260726d0d8046cd1365f25ca31d9889cde2fd7a6ecca22865","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612718029,"kind":4,"tags":null,"content":"PR0zmMZVkfdzsJriO3ajdQ==?iv=bML7a18IESplBVx2Fo5gsQ==","sig":"23efe1d673d3d6b10dc1dc21f573a1ae925917435a68622121b12bd539301c0ab112c555bab675a71a917cf155d5c39f90fcbcec7a2b74697114e88733376ce0"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"beb313771c25ba6a62f40f24eed6eaf7fea82a7fac8cfbbdda78f3b4c7ef208f","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612718038,"kind":4,"tags":null,"content":"8+83lQr6CjtxUV8jmh/sVw==?iv=TBpFUY7D/sNPSbTzToMn6g==","sig":"47c0dcdd9c432fa83f3153cad76359cc74cdceefe7f9c66db0732091f8367b57c6cdec7f1de0d0f395099da9ce2c52069396ad80ff8fab302689ee13217a927e"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"b6efa79297151c0f25948ed586c5fb23c04453043cf24d961e8d0eb8b8c83707","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718072,"kind":1,"tags":null,"content":"Not getting any posts yet?","sig":"70c18033c32b3f95636b15d6013d18360bb734f5bcbb1c8bf5993d8f890e75e4158531a0ab9a48d49bcbc1b94f94163d6c80d9b89e6bf23edbda9d14fcb16d6a"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"8a0f6f93dd51c279527e891dfbd57ab95d02d9fc7d331418d75c7ea79158a0fb","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718144,"kind":1,"tags":null,"content":"Hellooooo","sig":"eb2fff856a92b2518bcc90fc965d527a57ad91a9ce6dabf5c96d35d6c7440036ed9523157521ad7cfc3327a540c392c4a751008cc2ebf3b433d81f535c45ba71"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"f450d18411b503b30d5a8cebaeb0480ba6c8eb67fda572d04c63fb900d95297a","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718156,"kind":1,"tags":null,"content":"no","sig":"9a0c6663fdb527f8dbd1668acda6459814c738918cece209f7fb65f973cc3fb32ee7fb61e5f9e461f443d6f775a795498bcc19db2127803a8588044f07bdca0a"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"9d73afa3e3ea6b4e930504bb34c8066d655d1878fdc1cff39ed9b093fc3324ab","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612718170,"kind":1,"tags":null,"content":"I'm fiatjaf, and I can't prove!","sig":"89d217bfc23463fb326507765a35661ee64b2e474656bacf3511423f2784a47c1a163ccc6bcc054440b40310bbd280e712fb6cdeb23c1d486908b5e8b71e55fe"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"c9ae8727e529f4e6e6a24ad476c13b90d550adc648693b764a7ecf0b15b83909","pubkey":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9","created_at":1612718177,"kind":1,"tags":null,"content":"I'm akovalenko, is there anybody out there?","sig":"0f31cb6077b169f4316c7db18ea1b8e0e6d6ac408d563ecfd75d26c2cbe6bd0f441f8cb6759d4cc13f4d5f13470a403dd77cfd55a104ce50f975a8a2d0f16a9b"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a6ac3698abfa3be971d038769abbe4448615ee07ff19c9f727a48cf8ccec993e","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612718265,"kind":1,"tags":null,"content":"hello","sig":"bb261862a595791c504ef3930a03d10dc2c784a5f9e9a912d948ec32617e0b2c6699b1bc3d8af3fa195bbe8f9a79d3170fb7b3dd66a1b0d23d43043bbce85b33"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"08a572f46715562dbbd4ba6687786acb9b1c2a4d2e9b627f605f782482c174de","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718310,"kind":1,"tags":null,"content":"Wow, got some data","sig":"f632c4485f8165d2323e2f11bca7a5d08b9b041efcabb1da76382b52fb703f44cf7d6aa4a0730d3e992d94b17237cb12cb87138685d431620a4e5062ef328e03"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"951b0c82b9b4ba64762d948254f6a909f40891ad5535d4520df227ca439b6cfb","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612718384,"kind":1,"tags":null,"content":"I just see a ton of gibberish","sig":"8a5778393a927f172a63bbca5125725cab01f73c5d1d5e437c672e183fb9944006ac6d1c453f282e535d1f4c15878400999bf154eba7fd87a135759e07ce95f7"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"643f09d78fda9b1747f701b4d9031a96a0ab8109fc67049d5303a92783e0fac3","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718575,"kind":1,"tags":null,"content":"Yes, the rate limit is great UX","sig":"7784aae1d430c93b20a351a2e603a68913618ab68aaa39c1e05c619b986b69f55cba4552973564b08dbe51223e070ee765bfd377224f60d9278f267dacef52c4"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"9beb6fdad93036d38a8c12cbefc39b4b9867b18e43b4329562be04ad3c292045","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718709,"kind":1,"tags":null,"content":"Anyone tried this bot on telegram @zlibrarybot?","sig":"de19affb1494c650c980b9b7743ba1d90750e4ce6f757c2340eb6d50d7b2ae78d6db508ffd6508cfba256b76aa075085f04b42ce35ffe10434faf5ddee4b1d02"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"36220ea30231dc02aca75151bb22097497cc32d86d091cb4dbfd2a27704ea463","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612718724,"kind":1,"tags":null,"content":"Just put in the name of any book","sig":"4eaa22c253de6d40f17b0b1a5fb9055ee2a533f30dfb0354ece8b19913b3231196d66c31cb5ea846629845995afa5340cea894dfed62dc34e21e6bb081598f56"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"45880a8e7effae31debc120bf563894290ec0f6f1cfdfa18c37384297633ce99","pubkey":"4c26f43c256ae19fd025e6a619ae9b3ddc7092568d464613d94d05e6c573d4af","created_at":1612718750,"kind":1,"tags":null,"content":"Hi","sig":"eb9aa9cb0c341c3ed87cbe92b8e532226fd0fff6b93e5de67b4bb9329c479fd9611740cd48ff4e0529263f7a61cb7b48da075020da4cb980b94794083f5ae935"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"e914eb8271ea891b45b9fbf7cdd01b3cf09861b9245d2f9935f1f2a9f1ffa690","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612718792,"kind":4,"tags":null,"content":"yQylpih6SrlVVusFQodcpQ==?iv=wl1KlrC5A5qulcTkzA/pWw==","sig":"3347c3d08dbccbcbed7dcc5ecdc7d8000ebf60f2e6f2d15ba85b11ae214703949f1d77614974532bfb2aae51189f0c20b7743b1c65a278cc4060ef9713c4045a"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"3366fa6384aa042317f76656187cf4629225ed3246c5cd69a73e46a5a28760dc","pubkey":"359322282153a422b8f5e727acbd55d53f1e7a3aa76651d903c8f33eed316fce","created_at":1612718848,"kind":1,"tags":null,"content":"Bitcoin","sig":"302e913d3234356a5dde9b508cd9c614310398e267e86b1a77609a51a5a23a2e578a5d2a7f9d66436c62927c23c1272f307896f0a204c2c16285a3f69290e74c"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"128f4f9eb1856893273ac91aa9544f02a9b7bc0e701a16449412f46e9f283db8","pubkey":"76c8658c827c8f4c1680be9f0927882c73000aac1ca4f70cdcc0279e39962309","created_at":1612718889,"kind":4,"tags":null,"content":"H5Fvj/EuZ2sKme8h5wZ+GQ==?iv=gpRePDQtugknxvORJ6PPgw==","sig":"87f1847c98d8776447123075f5139993ac3fd19738a55c359bd64acd2b0ad1d3ce12e1b04703536a488bc061e17719fb709ccc14cc109a95b2746ae527abe090"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"ec39944536c3f9524c434c6d91da99c4dcb553fef16a9cc3ce2563ca47bef081","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612718919,"kind":1,"tags":null,"content":"so far so good","sig":"b1747003555faf4bc5310f20d27f77cbe1877d31c64cd555701153d149b5799ca92bf38efcf89cf336e005627620d4063f497c3f9a9abe278e3a4329bb32494a"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"3fa4586a8d0bc29c236d07a553dc4204a0bd51ae13adaa9539e0ac66b1b64009","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612718994,"kind":4,"tags":null,"content":"PPh2a0C+4VBpPydvH114kw==?iv=pCny9BX3pzh381QMODZfSg==","sig":"b0d4258ca804e1142a9735d075c73457b9ab213eaed1e8336046ae4d9b422cf74178e3b0e640b64ddeb40584f600b236d2c81f19f51b838fdb69938f077e991d"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a724ac4709629a4554d4a3b392a71beb5a0f365288aa6b1a8057957ec3c9b2ed","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612719055,"kind":1,"tags":null,"content":"ok, I've removed the rate-limit from my POC relay code","sig":"0f908b235639180e4e906c27a5ca0b5efa97a746c05ada7c48341a144bff2919f3bef692b3894ad33dea494c0dd9c15aa83f5011c1f0b77656016dccfd1d0f67"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"8e476e2a1d9c52cc06e6f2ec2d74d5842f84e3e00a2a42191c095f6e62706de5","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612719085,"kind":1,"tags":null,"content":"but I still think clients should be aware that relays are not to be trusted and they may fail for weird reasons and so on","sig":"d9b80145fde32ffbecf0456ce69b1b7a55df0d5a01210dbe3a4c22050171aa61d5cef9e202d121987d07590c84124fb4e4c566235ec9f28f4c5f783c0ea5717a"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"6ccca21b5176945cfc303825ce18aea2e3023680375d8e57788190399b09e8d6","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612719122,"kind":1,"tags":null,"content":"I get a lot of random relay errors","sig":"5c60bcc689fccc32662ae62004c22f81ee6d3ea6dac0e603aca2ef807a2b7206fc0ffb7c793e5930c13f0eeb55de5fca5cf9ff00ba8840bdcc52dfc0248ecbba"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"3c3441e03c9dd125b5b4ea0d456ab731653e888c4a4961958cca832be388cb46","pubkey":"2a6becb02af9a9c16f78473bec828396ff39856e6ebbee8ff9a40450d5585a71","created_at":1612719134,"kind":1,"tags":null,"content":"Hello, World!","sig":"19639912ff30544da35da60674f830888ef2139dc7c9206003d440a859a02704f19287a1f3b23814d0485a69a6342e24583a1b0064c45f32d0563786b3c2fe01"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"d977fc22309d501976ab9a317ec59d88802bbdd8cac2d1e0fae24b0b1993c8c7","pubkey":"8fb2b4dfbbad68a3001d3e25f745de57c75647e8cb9ae0c3cc50f297933e4337","created_at":1612719190,"kind":1,"tags":null,"content":"Hey Nostr!","sig":"3c09e5737d588b498454581dba7a1ff972f3d34bcd39441ba5421338d4b05de16507b07402aa7d2649a7bebc571bb1e9769877df24e56ca7d44732cbd6f2e4bb"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"c3ea79a52feffa76b10f26c5041b2493545b27668bcb9abe0cc4348acf0852f0","pubkey":"8c79b25dda02684757031cc31c05ed8c17f5d6111b2b80f825945b838450481c","created_at":1612719317,"kind":1,"tags":null,"content":"*insert Jack Nicholson nodding meme*","sig":"ddda774b976833aa507422fff17d2090814be63db3580aff8e83822aa9ab892b7bc58a3cdecf06821a8ba37ed9b30bda15682d46cd24e7537c2c4616d8d91fad"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"9eb12cda084c1e6692e9421c803c8c4970a658b223c28eb7af35933a246076f6","pubkey":"6cb51a92f6c1ecc613db13fca5da2dbcc23bde6613eb9ea7cb79686fea6ca221","created_at":1612719361,"kind":1,"tags":null,"content":"Hi","sig":"204ba930638a2ac6ab7c8eaf264a529a589db2f5ff8f54076e55084edd9d65325bef86f5f65ec23aacbb0190bb2ace4624d9fe33b5815a6113325088fecc5f59"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"4de66a382a962f28c755979b85118a5a940bc118742fbc6fd98dc9260ba437c8","pubkey":"8fb2b4dfbbad68a3001d3e25f745de57c75647e8cb9ae0c3cc50f297933e4337","created_at":1612719388,"kind":4,"tags":null,"content":"+jEjChxHpBFL9YtMzMv6Sw==?iv=Fc8TB1CFkOEV3Pd2BoBhSQ==","sig":"a666adcb630fe810a75e523d8e80295355f259c22e5818a1703bee1556217cc932d04d33c7100dffef01dbf120888b5221001d544ed01bbf0cc6f20c572b471b"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"635ebf9aa3adf665bc5fbead6b45e1bd3d8933d2c3fea82989de2729eb352f66","pubkey":"6cb51a92f6c1ecc613db13fca5da2dbcc23bde6613eb9ea7cb79686fea6ca221","created_at":1612719522,"kind":1,"tags":null,"content":"Hello","sig":"3b07c57ddbfb281c39b4d9e238e760f3e7f8c81e9d6b4fb960ed3005bc4de4c501782d8e926ac5039f732d010a462aefaab83b08caddc1fc4b2089e6a56f37cb"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"033e38ba1e59cadcce55434d9d416406f774fead3fce93226c8fbca9e919005c","pubkey":"271cbc18d71f9eeaca6693dcbaf76a54730e80c8dc7d2b11d5d1b242121f1555","created_at":1612719720,"kind":1,"tags":null,"content":"test ","sig":"c3a7ee3a08a948364ba00b490685516683dc6b6b283fb2ed013a6d08c231f82aec4ef6c876e0c46c161a05794c9dabf128bc8e2841b8ce74e20dfd285d648def"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"37ab40788c8ac4e3a6dfe2be16647fb68122fb9d3995e2f39675e0dbfd1bc36d","pubkey":"8fb2b4dfbbad68a3001d3e25f745de57c75647e8cb9ae0c3cc50f297933e4337","created_at":1612719782,"kind":1,"tags":null,"content":"Hi, I'm Elon Musk! We celebrate the TESLA anniversary this year. We give away free BTC just send your BTC to this address and you'll receive the 10x back!!!11!1 Guaranteed!","sig":"89cdb3791e845b42da1326415e0602dfb18551c7a0f8b67f2c250397e52c1b500036c3e8e40e844836eb9b868ff105d065e0cf26b2777e02589a47e1cc88cc76"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"463c161858ef6a6f81a702cba1b2ed18dd8e4f13ca5a02e6abb2d912b7248cae","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612719841,"kind":1,"tags":null,"content":"I deleted local storage, got my feed back, got dms but lost my side of the conversation","sig":"9bb358ba036273dad1bff21d6aec38d47f5026325e849db31224cfc579e2fc3f8c27a3a52dbc85cc2efd86a10e041e2fd8c4868fa842b412dad63f85367ae344"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"21f6b8d34b297e56f8b1df778a146beeb441dbd0209f7eb798e7c0dcf7ff745d","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612719895,"kind":4,"tags":null,"content":"AM20BSQKE8B7S3P93h84KQ==?iv=RTEAOmRoDKdR/pJ08KmJrA==","sig":"fd9a740788fe1bc67de55ccaaafc487cbc879988fa4ebe900f0751f824d75863e1d4c28b29d3054f419a4492fceab17732b1c7311ba8ac2cc2e7106e55b9040b"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"77468731e429b95e5e086f58c9d2a30c5631ea3800b3e2c359890d151e06158e","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612719901,"kind":4,"tags":null,"content":"nSpVaB74d6Fco01YFdLIgg==?iv=3/5tClR6B7ZdDcEGf38Aew==","sig":"84a32b0ab26f5d9d47701ab189af3fc9331ba8df90665bff94c3d222205810c05ece0abdeccbcb26ede41b7bec6f557d057d37f3ce571d3c65541f66deb550fd"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"bed9c3e1734b93e1d6689646fc692f7605e6adeb4bca90ccc161e6fb7557c275","pubkey":"97d90ee5725feb457a9ba7969acdc92049b2f16471e6b50596c378ebea48ee55","created_at":1612719940,"kind":1,"tags":null,"content":"Wow, thats pretty cool","sig":"3207436a396788597d6ca7a2be4bba2b9c480aa208c374526b8001276cbe0dd547fb594224edb848c238724ccb9df76059ffcc6c792653271f1140b470ed2385"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"03e0972dc6a94e88ee7bc1cdb5ba10bb67122f5d9d57bed0d99847d670773214","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612720554,"kind":1,"tags":null,"content":"Test from a new relay","sig":"0d927de5915c12de773e17ef4db3bfd39977c937d97631b8f0b82d723feafef13ce4e96d95992f02e93dea662ff82aca2adba70ca48b1c7c5c780eb01dea5bff"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"38d696d8b96200e10f1335f83cc2ef40393021336cc9cec254b7c74b97c8cf84","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612720654,"kind":4,"tags":null,"content":"TzyTqitK+Adv1cgSe1trDg==?iv=ELXw7Ije8Q074chj+uGS2g==","sig":"1d706b49307a172c8db00b475b7abe9f159b088dae8d054fb40c0fd7aee3763897ecafb4dfeda065c2ad1784078c07de7597b6fa88286a0697c9c74606f6c186"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"35f9708ff0bae02ce4fc3799f50fcaf1affac7ba809cca9e18a2bf70c601a0db","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612720665,"kind":4,"tags":null,"content":"todzDCDEUecb+H4fmkiwzA==?iv=7ecvtK2r/kOyh6fyD79inQ==","sig":"f7dade434bec0c43b68397893a8c06c439f8ab628a347381cb2b509ccbb0ab3af96fa50dcc81166cb0781d408e8328f207ce05e73503164fbca1702d9ac4d837"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"e5130088298114e5b3cda517dbfc76999ddc758e3462db28236b2ec8deaea84d","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612720777,"kind":4,"tags":null,"content":"+sEvKff5h25ieydxbT6V9w==?iv=3IfPlCKjWN1qKACBrikGdg==","sig":"e00183869031142455c10722b6b9c80e7de0b7c32aef743af102ad4621df83583cf68b1686f9ca6cd6ccfc6d9b9dafca632d10ebf7600548ce6af83cd968ae3f"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"3b7444dafaa8ba360028d1ca352558eec138380eab6d41f269a90f53f2d1f76a","pubkey":"6466df2abb1b1d0114305d0a11397411871a2ed1b133b7bc8299f958b65fa491","created_at":1612720778,"kind":4,"tags":null,"content":"KX321Ey2RZN84rro05lxLw==?iv=WK18NePy+Kb+TCYmH+j7Gw==","sig":"0fa034dba846c0a459fc8943c7fd93f72baefb8af9cb8ecc17308e8f9b7a45f2a839dea46f90df6f5cfa048d2e02e9b82de43820dfe6ab4e1cd1611fec4f91cc"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"257ddf06fe141133fc3278ea3caea681983a128b2017c1a7310330b7f3b6685b","pubkey":"da5e9e12f0ee37574fe8875e95f53057a7d9345ad82b692712a0c2c9a2a53c02","created_at":1612721689,"kind":1,"tags":null,"content":"Testing...","sig":"c6a51c19e6f1203801f16a4999dc3e6c172925fd4767d3dce818c38b41cdeeac788b0048fce42b9e09a5bb9bb3687c257a7f72920959187441c2ccb6e6774705"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"f4d3a6d1287a75266c5afc1672544bc3157de1ccecd15bf843202d2bfb218a02","pubkey":"2a6becb02af9a9c16f78473bec828396ff39856e6ebbee8ff9a40450d5585a71","created_at":1612721718,"kind":1,"tags":null,"content":"I didn't find it in the mobile application Add public key. 🧐🧐","sig":"b69226f5b17b1a610e81547f78f117419bcbff4230eb2ed745f5c3bffe3575ebdc170a580c2c4ad055c53a941481ff5906f60167d48cb02f93b673eb950ed919"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"4db57707d52e788a2d5f37fce5c74a7a67e82f102d11e20ea6d315b21e644cbd","pubkey":"46635e6e168d3daa4961f854f688428c5c4dc47faff5b4e2930f8c2394e0b6df","created_at":1612721776,"kind":1,"tags":null,"content":"helloooo","sig":"23f038a45cbdf38b002eca22f7d6e862f2e57c37e2d79b80eb4cd629777afd0e03bc1edd54dc8f8921684e8f2a17870d56c3598fb50e0231f5a481d9c92db3cd"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"12ba7e31b8f7e9aa2f210f03dadfdecea331ab3f8ead3c7a1cbf8f4987ac11d9","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612722130,"kind":1,"tags":null,"content":"my relay is fixed","sig":"7faeee90de41eb67bce29f03d0c1ed98024e9f4f83b9875511dccfad4fe2dc9370ea8099353a13eb54b2411778a49fd3c70b83fbabdb0b4c4f673fda07a8979c"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"c782268e60b28e3b01fc094e59716753aa6d11085b4a0c43c1f7999c9df4fb24","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612722180,"kind":1,"tags":null,"content":"I am `systmectl` clumsy","sig":"230748aeb559d1d9b9b4aae3b74fd9b5e371adfcc9a80d9b67c05d664204207bf1f440eb4a8b8959b43bbe1789bbe2b762e4aaa94355d0258ebea518a6cda0d9"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"762ad4264bcfc5594d495ca737a041c92f83206b8a65d57c33afeaa717697ef7","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612722208,"kind":1,"tags":null,"content":"I think deleting the scroll thing worked","sig":"55138849fd4debabaa9432aab7bd3f71ddf79c1c33a8eaa7549ae42c173ae0a034ce729d6a715092cd79615ffc398dc36d499ced631ff69dfdcf63cea14379c0"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"97c41b68d39f383c5820bea80cfead8066b42cb94d536b75f1601159c999ba3a","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612722277,"kind":4,"tags":null,"content":"oWSXntrVNx4bYaX/jvQ00w==?iv=UP+cLHDTS7FeleAVbn542w==","sig":"8ecc4a01011292e88d9b01917c3934ba1035152edec5b56a33a3bec56c3ab0ba357b3976ccab868df5ef73413962b15c957e70ce6c5e325d0f5ac82202ea4ade"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"1f868ba3e13c97ef0f2d622d2e4e091d53652305aa542ef7f5cb07790e57d62d","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612722401,"kind":4,"tags":null,"content":"3kS0ICsW17CGSrXFD6MjDw==?iv=Cm3hZDxNSMCp6LzgesXU9w==","sig":"3726851fb326210d3f5ad20efc2fb636a8bfef515a41fd55d82842004e2b0e6fb3d79f58af1963311c0e2a753f6373296bd97634779a593e953175716bc4bef7"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a23bee31c65324a70c1696533040f1ad13a29d49ea7ac3faac6f7d4f490f0015","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612722420,"kind":4,"tags":null,"content":"HaE5TWUEbtI8+gcHcwccNg==?iv=9nE52WRDvEv92RKUt8/iCg==","sig":"3ce2014c6a605ca785c1315a2620b357e19ad8332c119ce392dd260b42c59552d4a157f9e367ee88d0f55994805770cec4c5947b26847d747ba2f7f50613cb43"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"91b6bd660a5c736490585c5035d6b4232723175276fa7fcb20de4d6ff2d488e9","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612722902,"kind":1,"tags":null,"content":"Whar","sig":"b3c0c1c6c2cd5f2acaf2fb5664d514dcb5a7347c9871dc342a3a8db10364c0c18785573b13f8107fa36e336566e2d155da735979c359996c65c9abf40909ad71"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"43938617983503dac8789a5e1328e9e6cba630d0038935130b6a08013199d47c","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612723064,"kind":1,"tags":null,"content":"What next then","sig":"d16d703d7919b8170acb25a7c68e2919b46723575abc017d9cb28273c8387e4fb6a31385db07ff89ee07f2c82018768e0ad052d72a6a6d9f289e45fdba58d140"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"7fb1d4d16fe68d0b1fb5c5c229505e8f581f8f1e24dc19ca87ba712224ce500c","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612723635,"kind":1,"tags":null,"content":"Scammer","sig":"a5247f0b185a50ec1d13a776b52394c24e07a668caf8ce4c20402ef23cf62a9b362cf8c18225fea2b7708680a373855e744fc09933da53ded662dcef68fc4d9c"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"d4223bb3e51a2e79b7e1f71707d7dfa3572fddd97c99e9c231e636e7d217b345","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612723646,"kind":1,"tags":null,"content":"I am on the netlify instance now","sig":"f4aafcc264ae078e5faded7af12f49be4db4fb28beabb48a837fb8b7a9374080b881022784eac187d4736e48b7865c9be2f7e3fa0e5b58a3ec4b02d25e738186"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"7b9db98b22b9d33d8782c2be56265c03fbc5302118cc80634d92abc2412489ce","pubkey":"42142dc712ff8d1a4aa0a3f6990a60dfde6a19fb872e6b98d50383b8f36d6491","created_at":1612723968,"kind":1,"tags":null,"content":"hi bitch","sig":"6cd6bd473c7319a57ec687d77b1f87bf515eeaa70b2559d838b3d7407b576b96b779c73449eef7ebcc63302bec4bc841ad88445ed734eeb1e4b8a122857288c1"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"13586d7130e6cc14a684c4ab1ad578e410183cfed8be34f48dfdc1dd02259ce9","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612724128,"kind":1,"tags":null,"content":"netlify try to not break","sig":"618109dfd3734f7247fc7702f5e6198509785d5da0d9328eb6d42f5880ddc75b056e5f9521a424e3759a44f0b64300c22557fd1e7e9f89c35f0b0e6edc51035f"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"9a0707d478c45ef06eabc412ce05f7e7bffd260d509574bfddc3f55206daac63","pubkey":"34a229681f989d0357a60f89600f150f30b8ca5879a8e4b6fe29b451d3eec3d9","created_at":1612724140,"kind":1,"tags":null,"content":"hola mundo 😎","sig":"db08c883ce6adc74e09eb8eab86f5f013bcbcbfcef247e43c3ba87d06dac504163481e76820574c704ef9414af5b1e39bb1c3907dfadf60f2f79c865763d5d07"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"2f26cb9643e1ac11c4933785c412a15b61267be3f7141817f44df3fa2acea8f0","pubkey":"7859989f62f49100fb71fce64ef03e0a89fb5258e866e7e70fbdfa83d09d016d","created_at":1612724287,"kind":4,"tags":null,"content":"2g5kgiiGNmetO4p6SdQd4g==?iv=naRaO8xDiGtUyNDeuUuH1Q==","sig":"3a414f0df3f28f0a1a93741b6cc30e4966967adde6fdb8bfbd816051af39d3d5f208d11f610952208917567b276037986187e103458bd8caf42e0a75632b22fb"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"8e7ede8bdd4dc1ce73b242e9769a28af35025261e5c15c0b8e390973bd87619e","pubkey":"97eb9cffb7ed45bddfcfeef4ed158509361f459df5e5dc9a5e20e6e9e7f3292b","created_at":1612724442,"kind":1,"tags":null,"content":"Running #Bitcoin","sig":"74f126533fb2c127e64936923323a9bea47d5835296f45c52f244c3268234a942d6ca94c247585fb94ad375afe711b88a3a7fe75138280e96520d07859aae7dd"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"920a36e97fa1e6efc720eb2d633e9aed1c525bdc90a69b1c9edf4f4473d85d08","pubkey":"38ffee9a31d0ef9d4e34f8af52a63fd569535edf33b29475b17ba8a05406cd5a","created_at":1612724458,"kind":1,"tags":null,"content":"Hey","sig":"c2ec9ed12eaf40d7b12a6973aabb861e23cf90ea6c51ef321544d4d9a405b1f895426a1a3036a6d475f46968539e1e681b8839ad30bed85679c5c4f56cc6d5a9"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a0cce98db0522ffb2416db0805d91a03834fcd15181a9f449e36485c61fbae80","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612724556,"kind":1,"tags":null,"content":"netlify should be fixed now, mostly","sig":"4dfe55db62725b39054055eb1de844373b7f21ed3274531cf010542bf448ac8b842a719af171db1f12842be441c5cfea2b3b295150e38c7b6e1716fbc20a10ec"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"3b9467414a319b4331c96375e2d1b553508799fd83bc3b83de26066a7aa395be","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612724888,"kind":4,"tags":null,"content":"gxJTObw4DhMe5r/b5CNnPA==?iv=FTPzPR5sbzCqmYMZ5TA4yg==","sig":"4a75cce11b98ccad8eec1b35352931433e48f44b2ea592b7c8dc42003738e19bdd68a72e73996940aab21d2916722b80f61240cd7730ace135c366549f98e102"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"d8683498da8ea9ccabf0a7b713d134fca696e809b5eedd2d2e792139c0fc5693","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612725164,"kind":4,"tags":null,"content":"2bK8qPakfVGmuR5YLQ86qg==?iv=tGyApLaIMpjNhckLINqnCg==","sig":"4e3590ddd80efdeebbd5fd36eb7049447ef1fecf04763be71258dcec0d3e99a6d39f3ef12eaa9479fc1c7e7b38f73d50ad44beee01cfc9ae26e1a0e417a8119c"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"d50f2b453128dcd3f3fb1ab0504d817f5ab657be8105b78fde2bd98b9ef3ec40","pubkey":"e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216","created_at":1612725187,"kind":4,"tags":null,"content":"IhxR3DUK6cuusvxJaXQjYA==?iv=QywipN395oAmSpPo3v/ESg==","sig":"81d139cf9f9df56f560b92bbbc5c81f4a24b85358986fd05d4008bf4d7e9d505400191adaadfdb81ae0c06b4371b63a21ab09939f6fcdaa2c58bbf43afe13c85"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"e7cd845b72ecea0e409a22526c273982b5db3c724259152c80c704781b0515ae","pubkey":"08cc733d6660e1df8677f17cb149d177a2e19bcc51cb3993234e9ec51eb50a65","created_at":1612725385,"kind":1,"tags":null,"content":"Hey","sig":"11ddd49be7ab14fed60dd7efea317a25fdcac9265407e8865a7956fad56cffa6bf0ceefb8bca5fa86c99e29263f5fad1daecf439ffc03bc6a5819d14782ab6cc"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"6bdd3088c100f71124588dfe4806993e248dc6809f52cf13606f4f331b6e9b42","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612725524,"kind":4,"tags":null,"content":"RVz8i1lgi1iAh0IfQcZdkw==?iv=teFxtIOFeboJkXZl9InubQ==","sig":"a68737ecb7524a9c840face76af714934487564189bdfeb3668f5dea5212c25534a9770319722adab4a4eed9b9be20af62e0208ab71d1f6a456edfdc3136f7a7"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"75e3fafb8cb2b949e67a938cb5edefd2dfc527e47a5b439348130eceb131196b","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612725528,"kind":4,"tags":null,"content":"RiL/bP37fcHeuhkZT5hc7Q==?iv=bJ+5UxnBFDrWMW33TA7L3g==","sig":"bc99ae69cacaf0062ff57d8c66a0f329c5f33bacf741144e467fcd9ddfce8fb4c38f8e60cab3a557f6ee6950d8737e99935fa00c234d47047de3d82672c4e128"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"4cfe3bdb4c706339257710d4312b2ef3825aff4e0bb5cbe3d51e62ff2308fc1f","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612725730,"kind":4,"tags":null,"content":"dYeM0n1w5gClZxvP/+BGiA==?iv=ddKhdGnlsQ6NKUQ+WOaPpA==","sig":"0b25b5696d8e79c758e66129db3de7d1dbc005fd416ecf31decfb8b0ffdc838528fe9cde3d520225f4d4fc963787a42f65e8c474ae6d935a375dc04603cc2636"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"fa7754c8e6d3ce77ec788a585385563582ef8996afb61eb66eaf9ec20bc481b0","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612725784,"kind":1,"tags":null,"content":"Well that was trial by fire 🔥","sig":"27d8ef4439e09eecb52c56886bdfd3882d547f34a7de05db99b174163be15fc016523fd31871ef630c2aa31e0dab0baefc0575be51abf2e373918d1e2ff55973"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"7b32445285e3d59c66ff74031370d506303c1cc254f361e1dd1ba0ac8660f140","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612726042,"kind":4,"tags":null,"content":"vuc8VIl0xz8b5kNIGfs0Ow==?iv=2KKjOb05Cd3kMn56PYEMLw==","sig":"14f9ed8ab8a811f8def79370c23be55738418e380b28b76fae5c09b38a34fb8d98fe724ff78eedcea280aad4b3c1200e9956c1c414abfcc813614d715191e8de"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"a7e8da90926dcaf9fa4a0897cc07c724f27b0f7725d538f807c6ee6108905ddc","pubkey":"901d6d9cfcb94291b8be03d44895224fc26c024c79efca6e6daa04589956887e","created_at":1612726051,"kind":1,"tags":null,"content":"Test nost","sig":"89fe7cea6e2ef532fcbeb1e25cac78c8d6d539c923d08ad75873831f6b281637ba440bf714f62f05e7f2bcc92b247d6b7bd1b61c03bab306ecfc02fd83791554"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"c5a6dcf0a1f9bf5072189b60e8e8b3f46b0ae862232de95b575424db9eb5b536","pubkey":"901d6d9cfcb94291b8be03d44895224fc26c024c79efca6e6daa04589956887e","created_at":1612726088,"kind":1,"tags":null,"content":"relay says rate-limit","sig":"62a0432144584e6dd37d1717f35e9a7d3a1c6db2a2d8e490322009946614c00fff9d7f2d39b0822f0abffab79ab70f15f26d3d0f26e1c742c7d17cadc772811a"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"002afa98c0293fa7b9846934d2fc9d0d4508d9e7767d663d25fd3cd2aecb283b","pubkey":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9","created_at":1612726313,"kind":4,"tags":null,"content":"NhLGT83vTqBayskoh6V4Aw==?iv=XGd0VFUVIri7ml9rRJC33A==","sig":"00307cf72b7b467175f5c0d29bd78af1a702589b7ecae0a9634c8f2fcc6d8391017127f46229b637bb44ed91d4e0c29361b70cd1e8c8e5036c30550616860ff9"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"75b58a2b7992fbcad1ce996ac1ad5daaf72b495063db6ca42c0b70c2ced4b5d1","pubkey":"b09f278c6551c398866bb408f9b34817cdc0c4a78582387a7ba62d2e97036491","created_at":1612726316,"kind":1,"tags":null,"content":"if anyone is DMing me I don't see it","sig":"02f8529deaf5cfa8c717ecd51f421c2ec0fbf58a4f1c66fc8462f678aa22ef49d497dd21bdd414899a8fb355d6808e325097ebe90a4641fc4a3ba8f5b60d1a57"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"e61e00cfee8917f0266b6b51e2888342d66cf2e416f155083a0bdda4b35689a0","pubkey":"b91268018358bd83c80b972c1c955550294169ce64a4daf6d5b32ab9be02843b","created_at":1612726363,"kind":1,"tags":null,"content":"Do you see this b09f2...","sig":"90f12736588764f22c0c75aa1f3d6fe58dd4c22b897d32659c5cedbd0b73315e8c41bae01277c43442997f91053c3acf1c968284df0053a3976c56393189138f"}]
|
|
||||||
["EVENT","5496183021909538",{"id":"b1b6fe662f109c4d60512c2e23a7c4e70c9b536a173c850efd68167779a7c0e4","pubkey":"5f1056c13e6f188a64963d7993bf45eccfb4108b839c579c480ea94d824e4712","created_at":1612726530,"kind":4,"tags":null,"content":"oidImNefkwTgiaCLZ/5veA==?iv=TM30ouqWWuSDIjgnUZvfMA==","sig":"b9be71cf72afb23135534d118abf16eb7cd0c2053159a5bbd9b4bb0e910f82ae8673c1cc4c6f0cbe614e2d30de3620e5337fb7d37edc6273c2916f6ad20af64b"}]
|
|
||||||
+21
-53
@@ -1,64 +1,32 @@
|
|||||||
|
//! Subscription close request parsing
|
||||||
|
//!
|
||||||
|
//! Representation and parsing of `CLOSE` messages sent from clients.
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// Container for a request to close a subscription
|
/// Close command in network format
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct CloseCmd {
|
pub struct CloseCmd {
|
||||||
cmds: Vec<String>,
|
/// Protocol command, expected to always be "CLOSE".
|
||||||
}
|
cmd: String,
|
||||||
|
/// The subscription identifier being closed.
|
||||||
#[derive(PartialEq, Debug, Clone)]
|
|
||||||
pub struct Close {
|
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for Close {
|
/// Identifier of the subscription to be closed.
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Close, D::Error>
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
||||||
where
|
pub struct Close {
|
||||||
D: Deserializer<'de>,
|
/// The subscription identifier being closed.
|
||||||
{
|
pub id: String,
|
||||||
let mut v: serde_json::Value = Deserialize::deserialize(deserializer)?;
|
|
||||||
// this shoud be an exactly 2-element array
|
|
||||||
// verify the first element is a String, CLOSE
|
|
||||||
// get the subscription from the second element.
|
|
||||||
|
|
||||||
// check for array
|
|
||||||
let va = v
|
|
||||||
.as_array_mut()
|
|
||||||
.ok_or(serde::de::Error::custom("not array"))?;
|
|
||||||
|
|
||||||
// check length
|
|
||||||
if va.len() != 2 {
|
|
||||||
return Err(serde::de::Error::custom("not exactly 2 fields"));
|
|
||||||
}
|
|
||||||
let mut i = va.into_iter();
|
|
||||||
// get command ("REQ") and ensure it is a string
|
|
||||||
let req_cmd_str: serde_json::Value = i.next().unwrap().take();
|
|
||||||
let req = req_cmd_str.as_str().ok_or(serde::de::Error::custom(
|
|
||||||
"first element of request was not a string",
|
|
||||||
))?;
|
|
||||||
if req != "CLOSE" {
|
|
||||||
return Err(serde::de::Error::custom("missing CLOSE command"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure sub id is a string
|
|
||||||
let sub_id_str: serde_json::Value = i.next().unwrap().take();
|
|
||||||
let sub_id = sub_id_str
|
|
||||||
.as_str()
|
|
||||||
.ok_or(serde::de::Error::custom("missing subscription id"))?;
|
|
||||||
|
|
||||||
Ok(Close {
|
|
||||||
id: sub_id.to_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Close {
|
impl From<CloseCmd> for Result<Close> {
|
||||||
pub fn parse(json: &str) -> Result<Close> {
|
fn from(cc: CloseCmd) -> Result<Close> {
|
||||||
serde_json::from_str(json).map_err(|e| Error::JsonParseFailed(e))
|
// ensure command is correct
|
||||||
}
|
if cc.cmd != "CLOSE" {
|
||||||
pub fn get_id(&self) -> String {
|
Err(Error::CommandUnknownError)
|
||||||
self.id.clone()
|
} else {
|
||||||
|
Ok(Close { id: cc.id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
//! Configuration file and settings management
|
||||||
|
use config::{Config, ConfigError, File};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use log::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// 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 Authorization {
|
||||||
|
pub pubkey_whitelist: Option<Vec<String>>, // If present, only allow these pubkeys to publish events
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum VerifiedUsersMode {
|
||||||
|
Enabled,
|
||||||
|
Passive,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct VerifiedUsers {
|
||||||
|
pub mode: VerifiedUsersMode, // Mode of operation: "enabled" (enforce) or "passive" (check only). If none, this is simply disabled.
|
||||||
|
pub domain_whitelist: Option<Vec<String>>, // If present, only allow verified users from these domains can publish events
|
||||||
|
pub domain_blacklist: Option<Vec<String>>, // If present, allow all verified users from any domain except these
|
||||||
|
pub verify_expiration: Option<String>, // how long a verification is cached for before no longer being used
|
||||||
|
pub verify_update_frequency: Option<String>, // how often to attempt to update verification
|
||||||
|
pub verify_expiration_duration: Option<Duration>, // internal result of parsing verify_expiration
|
||||||
|
pub verify_update_frequency_duration: Option<Duration>, // internal result of parsing verify_update_frequency
|
||||||
|
pub max_consecutive_failures: usize, // maximum number of verification failures in a row, before ceasing future checks
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VerifiedUsers {
|
||||||
|
pub fn init(&mut self) {
|
||||||
|
self.verify_expiration_duration = self.verify_expiration_duration();
|
||||||
|
self.verify_update_frequency_duration = self.verify_update_duration();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.mode == VerifiedUsersMode::Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.mode == VerifiedUsersMode::Enabled || self.mode == VerifiedUsersMode::Passive
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_passive(&self) -> bool {
|
||||||
|
self.mode == VerifiedUsersMode::Passive
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_expiration_duration(&self) -> Option<Duration> {
|
||||||
|
self.verify_expiration
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| parse_duration::parse(x).ok())
|
||||||
|
}
|
||||||
|
pub fn verify_update_duration(&self) -> Option<Duration> {
|
||||||
|
self.verify_update_frequency
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| parse_duration::parse(x).ok())
|
||||||
|
}
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.verify_expiration_duration().is_some() && self.verify_update_duration().is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub info: Info,
|
||||||
|
pub database: Database,
|
||||||
|
pub network: Network,
|
||||||
|
pub limits: Limits,
|
||||||
|
pub authorization: Authorization,
|
||||||
|
pub verified_users: VerifiedUsers,
|
||||||
|
pub retention: Retention,
|
||||||
|
pub options: Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let default_settings = Self::default();
|
||||||
|
// attempt to construct settings with file
|
||||||
|
let from_file = Self::new_from_default(&default_settings);
|
||||||
|
match from_file {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error reading config file ({:?})", e);
|
||||||
|
default_settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_from_default(default: &Settings) -> Result<Self, ConfigError> {
|
||||||
|
let builder = Config::builder();
|
||||||
|
let config: Config = builder
|
||||||
|
// use defaults
|
||||||
|
.add_source(Config::try_from(default)?)
|
||||||
|
// override with file contents
|
||||||
|
.add_source(File::with_name("config"))
|
||||||
|
.build()?
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let mut settings: Settings = config.try_deserialize()?;
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ensure durations parse
|
||||||
|
if !settings.verified_users.is_valid() {
|
||||||
|
panic!("VerifiedUsers time settings could not be parsed");
|
||||||
|
}
|
||||||
|
// initialize durations for verified users
|
||||||
|
settings.verified_users.init();
|
||||||
|
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: 16384,
|
||||||
|
event_persist_buffer: 4096,
|
||||||
|
},
|
||||||
|
authorization: Authorization {
|
||||||
|
pubkey_whitelist: None, // Allow any address to publish
|
||||||
|
},
|
||||||
|
verified_users: VerifiedUsers {
|
||||||
|
mode: VerifiedUsersMode::Disabled,
|
||||||
|
domain_whitelist: None,
|
||||||
|
domain_blacklist: None,
|
||||||
|
verify_expiration: Some("1 week".to_owned()),
|
||||||
|
verify_update_frequency: Some("1 day".to_owned()),
|
||||||
|
verify_expiration_duration: None,
|
||||||
|
verify_update_frequency_duration: None,
|
||||||
|
max_consecutive_failures: 20,
|
||||||
|
},
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
//! Client connection state
|
||||||
|
use crate::close::Close;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::event::Event;
|
||||||
|
|
||||||
|
use crate::subscription::Subscription;
|
||||||
|
use log::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A subscription identifier has a maximum length
|
||||||
|
const MAX_SUBSCRIPTION_ID_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// State for a client connection
|
||||||
|
pub struct ClientConn {
|
||||||
|
/// Unique client identifier generated at connection time
|
||||||
|
client_id: Uuid,
|
||||||
|
/// The current set of active client subscriptions
|
||||||
|
subscriptions: HashMap<String, Subscription>,
|
||||||
|
/// Per-connection maximum concurrent subscriptions
|
||||||
|
max_subs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientConn {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConn {
|
||||||
|
/// Create a new, empty connection state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let client_id = Uuid::new_v4();
|
||||||
|
ClientConn {
|
||||||
|
client_id,
|
||||||
|
subscriptions: HashMap::new(),
|
||||||
|
max_subs: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a short prefix of the client's unique identifier, suitable
|
||||||
|
/// for logging.
|
||||||
|
pub fn get_client_prefix(&self) -> String {
|
||||||
|
self.client_id.to_string().chars().take(8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find all matching subscriptions.
|
||||||
|
pub fn get_matching_subscriptions(&self, e: &Event) -> Vec<&str> {
|
||||||
|
let mut v: Vec<&str> = vec![];
|
||||||
|
for (id, sub) in self.subscriptions.iter() {
|
||||||
|
if sub.interested_in_event(e) {
|
||||||
|
v.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new subscription for this connection.
|
||||||
|
pub fn subscribe(&mut self, s: Subscription) -> Result<()> {
|
||||||
|
let k = s.get_id();
|
||||||
|
let sub_id_len = k.len();
|
||||||
|
// prevent arbitrarily long subscription identifiers from
|
||||||
|
// being used.
|
||||||
|
if sub_id_len > MAX_SUBSCRIPTION_ID_LEN {
|
||||||
|
info!(
|
||||||
|
"ignoring sub request with excessive length: ({})",
|
||||||
|
sub_id_len
|
||||||
|
);
|
||||||
|
return Err(Error::SubIdMaxLengthError);
|
||||||
|
}
|
||||||
|
// check if an existing subscription exists, and replace if so
|
||||||
|
if self.subscriptions.contains_key(&k) {
|
||||||
|
self.subscriptions.remove(&k);
|
||||||
|
self.subscriptions.insert(k, s);
|
||||||
|
debug!("replaced existing subscription");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if there is room for another subscription.
|
||||||
|
if self.subscriptions.len() >= self.max_subs {
|
||||||
|
return Err(Error::SubMaxExceededError);
|
||||||
|
}
|
||||||
|
// add subscription
|
||||||
|
self.subscriptions.insert(k, s);
|
||||||
|
debug!(
|
||||||
|
"registered new subscription, currently have {} active subs",
|
||||||
|
self.subscriptions.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the subscription for this connection.
|
||||||
|
pub fn unsubscribe(&mut self, c: Close) {
|
||||||
|
// TODO: return notice if subscription did not exist.
|
||||||
|
self.subscriptions.remove(&c.id);
|
||||||
|
debug!(
|
||||||
|
"removed subscription, currently have {} active subs",
|
||||||
|
self.subscriptions.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
//! Event persistence and querying
|
||||||
|
use crate::config::SETTINGS;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::event::Event;
|
||||||
|
use crate::hexrange::hex_range;
|
||||||
|
use crate::hexrange::HexSearch;
|
||||||
|
use crate::nip05;
|
||||||
|
use crate::schema::{upgrade_db, STARTUP_SQL};
|
||||||
|
use crate::subscription::Subscription;
|
||||||
|
use crate::utils::is_hex;
|
||||||
|
use governor::clock::Clock;
|
||||||
|
use governor::{Quota, RateLimiter};
|
||||||
|
use hex;
|
||||||
|
use log::*;
|
||||||
|
use r2d2;
|
||||||
|
use r2d2_sqlite::SqliteConnectionManager;
|
||||||
|
use rusqlite::params;
|
||||||
|
use rusqlite::types::ToSql;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use rusqlite::OpenFlags;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::task;
|
||||||
|
|
||||||
|
pub type SqlitePool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
|
||||||
|
pub type PooledConnection = r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>;
|
||||||
|
|
||||||
|
/// Events submitted from a client, with a return channel for notices
|
||||||
|
pub struct SubmittedEvent {
|
||||||
|
pub event: Event,
|
||||||
|
pub notice_tx: tokio::sync::mpsc::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database file
|
||||||
|
pub const DB_FILE: &str = "nostr.db";
|
||||||
|
|
||||||
|
/// Build a database connection pool.
|
||||||
|
pub fn build_pool(
|
||||||
|
name: &str,
|
||||||
|
flags: OpenFlags,
|
||||||
|
min_size: u32,
|
||||||
|
max_size: u32,
|
||||||
|
wait_for_db: bool,
|
||||||
|
) -> SqlitePool {
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
|
||||||
|
let db_dir = &settings.database.data_directory;
|
||||||
|
let full_path = Path::new(db_dir).join(DB_FILE);
|
||||||
|
// small hack; if the database doesn't exist yet, that means the
|
||||||
|
// writer thread hasn't finished. Give it a chance to work. This
|
||||||
|
// is only an issue with the first time we run.
|
||||||
|
while !full_path.exists() && wait_for_db {
|
||||||
|
debug!("Database reader pool is waiting on the database to be created...");
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
let manager = SqliteConnectionManager::file(&full_path)
|
||||||
|
.with_flags(flags)
|
||||||
|
.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(min_size))
|
||||||
|
.max_size(max_size)
|
||||||
|
.build(manager)
|
||||||
|
.unwrap();
|
||||||
|
info!(
|
||||||
|
"Built a connection pool {:?} (min={}, max={})",
|
||||||
|
name, min_size, max_size
|
||||||
|
);
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a single database connection, with provided flags
|
||||||
|
pub fn build_conn(flags: OpenFlags) -> Result<Connection> {
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
let db_dir = &settings.database.data_directory;
|
||||||
|
let full_path = Path::new(db_dir).join(DB_FILE);
|
||||||
|
// create a connection
|
||||||
|
Ok(Connection::open_with_flags(&full_path, flags)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a database writer that persists events to the SQLite store.
|
||||||
|
pub async fn db_writer(
|
||||||
|
mut event_rx: tokio::sync::mpsc::Receiver<SubmittedEvent>,
|
||||||
|
bcast_tx: tokio::sync::broadcast::Sender<Event>,
|
||||||
|
metadata_tx: tokio::sync::broadcast::Sender<Event>,
|
||||||
|
mut shutdown: tokio::sync::broadcast::Receiver<()>,
|
||||||
|
) -> tokio::task::JoinHandle<Result<()>> {
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
|
||||||
|
// are we performing NIP-05 checking?
|
||||||
|
let nip05_active = settings.verified_users.is_active();
|
||||||
|
// are we requriing NIP-05 user verification?
|
||||||
|
let nip05_enabled = settings.verified_users.is_enabled();
|
||||||
|
|
||||||
|
task::spawn_blocking(move || {
|
||||||
|
// get database configuration settings
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
let db_dir = &settings.database.data_directory;
|
||||||
|
let full_path = Path::new(db_dir).join(DB_FILE);
|
||||||
|
// create a connection pool
|
||||||
|
let pool = build_pool(
|
||||||
|
"event writer",
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
info!("opened database {:?} for writing", full_path);
|
||||||
|
upgrade_db(&mut pool.get()?)?;
|
||||||
|
|
||||||
|
// Make a copy of the whitelist
|
||||||
|
let whitelist = &settings.authorization.pubkey_whitelist.clone();
|
||||||
|
|
||||||
|
// get rate limit settings
|
||||||
|
let rps_setting = settings.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 subm_event = next_event.unwrap();
|
||||||
|
let event = subm_event.event;
|
||||||
|
let notice_tx = subm_event.notice_tx;
|
||||||
|
// check if this event is authorized.
|
||||||
|
if let Some(allowed_addrs) = whitelist {
|
||||||
|
// if the event address is not in allowed_addrs.
|
||||||
|
if !allowed_addrs.contains(&event.pubkey) {
|
||||||
|
info!(
|
||||||
|
"Rejecting event {}, unauthorized author",
|
||||||
|
event.get_event_id_prefix()
|
||||||
|
);
|
||||||
|
notice_tx
|
||||||
|
.try_send("pubkey is not allowed to publish to this relay".to_owned())
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send any metadata events to the NIP-05 verifier
|
||||||
|
if nip05_active && event.is_kind_metadata() {
|
||||||
|
// we are sending this prior to even deciding if we
|
||||||
|
// persist it. this allows the nip05 module to
|
||||||
|
// inspect it, update if necessary, or persist a new
|
||||||
|
// event and broadcast it itself.
|
||||||
|
metadata_tx.send(event.clone()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for NIP-05 verification
|
||||||
|
if nip05_enabled {
|
||||||
|
match nip05::query_latest_user_verification(pool.get()?, event.pubkey.to_owned()) {
|
||||||
|
Ok(uv) => {
|
||||||
|
if uv.is_valid() {
|
||||||
|
info!(
|
||||||
|
"new event from verified author ({:?},{:?})",
|
||||||
|
uv.name.to_string(),
|
||||||
|
event.get_author_prefix()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!("rejecting event, author ({:?} / {:?}) verification invalid (expired/wrong domain)",
|
||||||
|
uv.name.to_string(),
|
||||||
|
event.get_author_prefix()
|
||||||
|
);
|
||||||
|
notice_tx
|
||||||
|
.try_send(
|
||||||
|
"NIP-05 verification is no longer valid (expired/wrong domain)"
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::SqlError(rusqlite::Error::QueryReturnedNoRows)) => {
|
||||||
|
debug!(
|
||||||
|
"no verification records found for pubkey: {:?}",
|
||||||
|
event.get_author_prefix()
|
||||||
|
);
|
||||||
|
notice_tx
|
||||||
|
.try_send("NIP-05 verification needed to publish events".to_owned())
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("checking nip05 verification status failed: {:?}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: cache recent list of authors to remove a DB call.
|
||||||
|
let start = Instant::now();
|
||||||
|
match write_event(&mut pool.get()?, &event) {
|
||||||
|
Ok(updated) => {
|
||||||
|
if updated == 0 {
|
||||||
|
trace!("ignoring duplicate event");
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
"persisted event {:?} from {:?} in {:?}",
|
||||||
|
event.get_event_id_prefix(),
|
||||||
|
event.get_author_prefix(),
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
event_write = true;
|
||||||
|
// send this out to all clients
|
||||||
|
bcast_tx.send(event.clone()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("event insert failed: {:?}", err);
|
||||||
|
notice_tx
|
||||||
|
.try_send(
|
||||||
|
"relay experienced an error trying to publish the latest event"
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() > 10 {
|
||||||
|
warn!(
|
||||||
|
"rate limit reached for event creation (sleep for {:?}) (suppressing future messages for 10 seconds)",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("database connection closed");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist an event to the database, returning rows added.
|
||||||
|
pub fn write_event(conn: &mut PooledConnection, e: &Event) -> Result<usize> {
|
||||||
|
// start transaction
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
// get relevant fields from event and convert to blobs.
|
||||||
|
let id_blob = hex::decode(&e.id).ok();
|
||||||
|
let pubkey_blob = hex::decode(&e.pubkey).ok();
|
||||||
|
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, 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 {
|
||||||
|
// if the event was a duplicate, no need to insert event or
|
||||||
|
// pubkey references.
|
||||||
|
return Ok(ins_count);
|
||||||
|
}
|
||||||
|
// remember primary key of the event most recently inserted.
|
||||||
|
let ev_id = tx.last_insert_rowid();
|
||||||
|
// 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],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 for author {:?}",
|
||||||
|
update_count,
|
||||||
|
e.get_author_prefix()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 for author {:?}",
|
||||||
|
update_count,
|
||||||
|
e.get_author_prefix()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(ins_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialized event associated with a specific subscription request.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
pub struct QueryResult {
|
||||||
|
/// Subscription identifier
|
||||||
|
pub sub_id: String,
|
||||||
|
/// Serialized event
|
||||||
|
pub event: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce a arbitrary list of '?' parameters.
|
||||||
|
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. Strings that require escaping (tag
|
||||||
|
// names/values) use parameters.
|
||||||
|
let mut query =
|
||||||
|
"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", 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)) => {
|
||||||
|
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)) => {
|
||||||
|
auth_searches.push("author>?".to_owned());
|
||||||
|
params.push(Box::new(lower));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("Could not parse hex range from author {:?}", auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let authors_clause = format!("({})", auth_searches.join(" OR "));
|
||||||
|
filter_components.push(authors_clause);
|
||||||
|
}
|
||||||
|
// Query for Kind
|
||||||
|
if let Some(ks) = &f.kinds {
|
||||||
|
// kind is number, no escaping needed
|
||||||
|
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, 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 {:?}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let id_clause = format!("({})", id_searches.join(" OR "));
|
||||||
|
filter_components.push(id_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
|
||||||
|
if f.since.is_some() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(" 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, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a database query using a subscription.
|
||||||
|
///
|
||||||
|
/// The [`Subscription`] is converted into a SQL query. Each result
|
||||||
|
/// is published on the `query_tx` channel as it is returned. If a
|
||||||
|
/// message becomes available on the `abandon_query_rx` channel, the
|
||||||
|
/// query is immediately aborted.
|
||||||
|
pub async fn db_query(
|
||||||
|
sub: Subscription,
|
||||||
|
pool: SqlitePool,
|
||||||
|
query_tx: tokio::sync::mpsc::Sender<QueryResult>,
|
||||||
|
mut abandon_query_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
|
) {
|
||||||
|
task::spawn_blocking(move || {
|
||||||
|
debug!("going to query for: {:?}", sub);
|
||||||
|
let mut row_count: usize = 0;
|
||||||
|
let start = Instant::now();
|
||||||
|
// generate SQL query
|
||||||
|
let (q, p) = query_from_sub(&sub);
|
||||||
|
debug!("SQL generated in {:?}", start.elapsed());
|
||||||
|
// show pool stats
|
||||||
|
debug!("DB pool stats: {:?}", pool.state());
|
||||||
|
let start = Instant::now();
|
||||||
|
if let Ok(conn) = pool.get() {
|
||||||
|
// 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))?;
|
||||||
|
let mut first_result = true;
|
||||||
|
while let Some(row) = event_rows.next()? {
|
||||||
|
if first_result {
|
||||||
|
debug!("time to first result: {:?}", start.elapsed());
|
||||||
|
first_result = false;
|
||||||
|
}
|
||||||
|
// check if this is still active
|
||||||
|
// TODO: check every N rows
|
||||||
|
if abandon_query_rx.try_recv().is_ok() {
|
||||||
|
debug!("query aborted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
row_count += 1;
|
||||||
|
let event_json = row.get(0)?;
|
||||||
|
query_tx
|
||||||
|
.blocking_send(QueryResult {
|
||||||
|
sub_id: sub.get_id(),
|
||||||
|
event: event_json,
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"query completed ({} rows) in {:?}",
|
||||||
|
row_count,
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!("Could not get a database connection for querying");
|
||||||
|
}
|
||||||
|
let ok: Result<()> = Ok(());
|
||||||
|
ok
|
||||||
|
});
|
||||||
|
}
|
||||||
+88
-8
@@ -1,26 +1,106 @@
|
|||||||
//! Error handling.
|
//! Error handling
|
||||||
|
|
||||||
use std::result;
|
use std::result;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use tungstenite::error::Error as WsError;
|
||||||
|
|
||||||
|
/// Simple `Result` type for errors in this module
|
||||||
pub type Result<T, E = Error> = result::Result<T, E>;
|
pub type Result<T, E = Error> = result::Result<T, E>;
|
||||||
|
|
||||||
|
/// Custom error type for Nostr
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("command from client not recognized")]
|
#[error("Protocol parse error")]
|
||||||
CommandNotFound,
|
ProtoParseError,
|
||||||
#[error("parsing JSON->Event failed")]
|
#[error("Connection error")]
|
||||||
|
ConnError,
|
||||||
|
#[error("Client write error")]
|
||||||
|
ConnWriteError,
|
||||||
|
#[error("EVENT parse failed")]
|
||||||
EventParseFailed,
|
EventParseFailed,
|
||||||
#[error("parsing JSON->Req failed")]
|
#[error("ClOSE message parse failed")]
|
||||||
ReqParseFailed,
|
|
||||||
#[error("parsing JSON->Close failed")]
|
|
||||||
CloseParseFailed,
|
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")]
|
||||||
|
SubMaxExceededError,
|
||||||
|
// this should be used if the JSON is invalid
|
||||||
#[error("JSON parsing failed")]
|
#[error("JSON parsing failed")]
|
||||||
JsonParseFailed(serde_json::Error),
|
JsonParseFailed(serde_json::Error),
|
||||||
|
#[error("WebSocket proto error")]
|
||||||
|
WebsocketError(WsError),
|
||||||
|
#[error("Command unknown")]
|
||||||
|
CommandUnknownError,
|
||||||
|
#[error("SQL error")]
|
||||||
|
SqlError(rusqlite::Error),
|
||||||
|
#[error("Config error")]
|
||||||
|
ConfigError(config::ConfigError),
|
||||||
|
#[error("Data directory does not exist")]
|
||||||
|
DatabaseDirError,
|
||||||
|
#[error("Database Connection Pool Error")]
|
||||||
|
DatabasePoolError(r2d2::Error),
|
||||||
|
#[error("Custom Error : {0}")]
|
||||||
|
CustomError(String),
|
||||||
|
#[error("Task join error")]
|
||||||
|
JoinError,
|
||||||
|
#[error("Hyper Client error")]
|
||||||
|
HyperError(hyper::Error),
|
||||||
|
#[error("Unknown/Undocumented")]
|
||||||
|
UnknownError,
|
||||||
|
}
|
||||||
|
|
||||||
|
//impl From<Box<dyn std::error::Error>> for Error {
|
||||||
|
// fn from(e: Box<dyn std::error::Error>) -> Self {
|
||||||
|
// Error::CustomError("error".to_owned())
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
impl From<hyper::Error> for Error {
|
||||||
|
fn from(h: hyper::Error) -> Self {
|
||||||
|
Error::HyperError(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<r2d2::Error> for Error {
|
||||||
|
fn from(d: r2d2::Error) -> Self {
|
||||||
|
Error::DatabasePoolError(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tokio::task::JoinError> for Error {
|
||||||
|
/// Wrap SQL error
|
||||||
|
fn from(_j: tokio::task::JoinError) -> Self {
|
||||||
|
Error::JoinError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<rusqlite::Error> for Error {
|
||||||
|
/// Wrap SQL error
|
||||||
|
fn from(r: rusqlite::Error) -> Self {
|
||||||
|
Error::SqlError(r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<serde_json::Error> for Error {
|
impl From<serde_json::Error> for Error {
|
||||||
|
/// Wrap JSON error
|
||||||
fn from(r: serde_json::Error) -> Self {
|
fn from(r: serde_json::Error) -> Self {
|
||||||
Error::JsonParseFailed(r)
|
Error::JsonParseFailed(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<WsError> for Error {
|
||||||
|
/// Wrap Websocket error
|
||||||
|
fn from(r: WsError) -> Self {
|
||||||
|
Error::WebsocketError(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<config::ConfigError> for Error {
|
||||||
|
/// Wrap Config error
|
||||||
|
fn from(r: config::ConfigError) -> Self {
|
||||||
|
Error::ConfigError(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+272
-36
@@ -1,57 +1,235 @@
|
|||||||
use crate::error::{Error, Result};
|
//! Event parsing and validation
|
||||||
|
use crate::config;
|
||||||
|
use crate::error::Error::*;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::nip05;
|
||||||
|
use crate::utils::unix_time;
|
||||||
|
use bitcoin_hashes::{sha256, Hash};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use log::*;
|
||||||
|
use secp256k1::{schnorr, Secp256k1, VerifyOnly, XOnlyPublicKey};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
//use serde_json::json;
|
use serde_json::value::Value;
|
||||||
//use serde_json::Result;
|
use serde_json::Number;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
/// Secp256k1 verification instance.
|
||||||
|
pub static ref SECP: Secp256k1<VerifyOnly> = Secp256k1::verification_only();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event command in network format.
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
||||||
|
pub struct EventCmd {
|
||||||
|
cmd: String, // expecting static "EVENT"
|
||||||
|
event: Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed nostr event.
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
||||||
pub struct Event {
|
pub struct Event {
|
||||||
pub(crate) id: String,
|
pub id: String,
|
||||||
pub(crate) pubkey: String,
|
pub(crate) pubkey: String,
|
||||||
pub(crate) created_at: u64,
|
pub(crate) created_at: u64,
|
||||||
pub(crate) kind: u8,
|
pub(crate) kind: u64,
|
||||||
#[serde(deserialize_with = "tag_from_string")]
|
#[serde(deserialize_with = "tag_from_string")]
|
||||||
|
// NOTE: array-of-arrays may need to be more general than a string container
|
||||||
pub(crate) tags: Vec<Vec<String>>,
|
pub(crate) tags: Vec<Vec<String>>,
|
||||||
pub(crate) content: String,
|
pub(crate) content: String,
|
||||||
pub(crate) sig: 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.
|
||||||
type Tag = Vec<Vec<String>>;
|
type Tag = Vec<Vec<String>>;
|
||||||
|
|
||||||
// handle a default value (empty vec) for null tags
|
/// Deserializer that ensures we always have a [`Tag`].
|
||||||
fn tag_from_string<'de, D>(deserializer: D) -> Result<Tag, D::Error>
|
fn tag_from_string<'de, D>(deserializer: D) -> Result<Tag, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let opt = Option::deserialize(deserializer)?;
|
let opt = Option::deserialize(deserializer)?;
|
||||||
Ok(opt.unwrap_or_else(|| vec![]))
|
Ok(opt.unwrap_or_else(Vec::new))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Goals:
|
/// Convert network event to parsed/validated event.
|
||||||
// Roundtrip from JSON-string to Event, and back to string.
|
impl From<EventCmd> for Result<Event> {
|
||||||
// Perform validation on an Event to ensure the id and signature are correct.
|
fn from(ec: EventCmd) -> Result<Event> {
|
||||||
|
// ensure command is correct
|
||||||
|
if ec.cmd != "EVENT" {
|
||||||
|
Err(CommandUnknownError)
|
||||||
|
} else if ec.event.is_valid() {
|
||||||
|
let mut e = ec.event;
|
||||||
|
e.build_index();
|
||||||
|
Ok(e)
|
||||||
|
} else {
|
||||||
|
Err(EventInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
pub fn parse(json: &str) -> Result<Event> {
|
pub fn is_kind_metadata(&self) -> bool {
|
||||||
let _e: Event = serde_json::from_str(json)?;
|
self.kind == 0
|
||||||
Err(Error::EventParseFailed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if this event is valid (should be propagated, stored) based on signature.
|
/// Pull a NIP-05 Name out of the event, if one exists
|
||||||
pub fn is_valid(&self) -> bool {
|
pub fn get_nip05_addr(&self) -> Option<nip05::Nip05Name> {
|
||||||
false
|
if self.is_kind_metadata() {
|
||||||
}
|
// very quick check if we should attempt to parse this json
|
||||||
// check if given event is referenced in a tag
|
if self.content.contains("\"nip05\"") {
|
||||||
pub fn event_tag_match(&self, event: &str) -> bool {
|
// Parse into JSON
|
||||||
for t in self.tags.iter() {
|
let md_parsed: Value = serde_json::from_str(&self.content).ok()?;
|
||||||
if t.len() == 2 {
|
let md_map = md_parsed.as_object()?;
|
||||||
if t.get(0).unwrap() == "#e" {
|
let nip05_str = md_map.get("nip05")?.as_str()?;
|
||||||
if t.get(1).unwrap() == event {
|
return nip05::Nip05Name::try_from(nip05_str).ok();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
pub fn get_author_prefix(&self) -> String {
|
||||||
|
self.pubkey.chars().take(8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
// ** [0, pubkey-hex-string, created-at-num, kind-num, tags-array-of-arrays, content-string]
|
||||||
|
// * serialize with no spaces/newlines
|
||||||
|
let c_opt = self.to_canonical();
|
||||||
|
debug!("Canonical: {:?}", &c_opt);
|
||||||
|
debug!("Canonical: {}", c_opt.as_ref().unwrap());
|
||||||
|
if c_opt.is_none() {
|
||||||
|
debug!("event could not be canonicalized");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let c = c_opt.unwrap();
|
||||||
|
// * compute the sha256sum.
|
||||||
|
let digest: sha256::Hash = sha256::Hash::hash(c.as_bytes());
|
||||||
|
let hex_digest = format!("{:x}", digest);
|
||||||
|
debug!("hex is: {}", hex_digest);
|
||||||
|
// * ensure the id matches the computed sha256sum.
|
||||||
|
if self.id != hex_digest {
|
||||||
|
debug!("event id does not match digest");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// * validate the message digest (sig) using the pubkey & computed sha256 message hash.
|
||||||
|
|
||||||
|
let sig = schnorr::Signature::from_str(&self.sig).unwrap();
|
||||||
|
if let Ok(msg) = secp256k1::Message::from_slice(digest.as_ref()) {
|
||||||
|
if let Ok(pubkey) = XOnlyPublicKey::from_str(&self.pubkey) {
|
||||||
|
let verify = SECP.verify_schnorr(&sig, &msg, &pubkey);
|
||||||
|
matches!(verify, Ok(()))
|
||||||
|
} else {
|
||||||
|
debug!("Client sent malformed pubkey");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("Error converting digest to secp256k1 message");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert event to canonical representation for signing.
|
||||||
|
fn to_canonical(&self) -> Option<String> {
|
||||||
|
// create a JsonValue for each event element
|
||||||
|
let mut c: Vec<Value> = vec![];
|
||||||
|
// id must be set to 0
|
||||||
|
let id = Number::from(0_u64);
|
||||||
|
c.push(serde_json::Value::Number(id));
|
||||||
|
// public key
|
||||||
|
c.push(Value::String(self.pubkey.to_owned()));
|
||||||
|
// creation time
|
||||||
|
let created_at = Number::from(self.created_at);
|
||||||
|
c.push(serde_json::Value::Number(created_at));
|
||||||
|
// kind
|
||||||
|
let kind = Number::from(self.kind);
|
||||||
|
c.push(serde_json::Value::Number(kind));
|
||||||
|
// tags
|
||||||
|
c.push(self.tags_to_canonical());
|
||||||
|
// content
|
||||||
|
c.push(Value::String(self.content.to_owned()));
|
||||||
|
serde_json::to_string(&Value::Array(c)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert tags to a canonical form for signing.
|
||||||
|
fn tags_to_canonical(&self) -> Value {
|
||||||
|
let mut tags = Vec::<Value>::new();
|
||||||
|
// iterate over self tags,
|
||||||
|
for t in self.tags.iter() {
|
||||||
|
// each tag is a vec of strings
|
||||||
|
let mut a = Vec::<Value>::new();
|
||||||
|
for v in t.iter() {
|
||||||
|
a.push(serde_json::Value::String(v.to_owned()));
|
||||||
|
}
|
||||||
|
tags.push(serde_json::Value::Array(a));
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +237,7 @@ impl Event {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
fn simple_event() -> Event {
|
fn simple_event() -> Event {
|
||||||
super::Event {
|
Event {
|
||||||
id: "0".to_owned(),
|
id: "0".to_owned(),
|
||||||
pubkey: "0".to_owned(),
|
pubkey: "0".to_owned(),
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
@@ -67,6 +245,7 @@ mod tests {
|
|||||||
tags: vec![],
|
tags: vec![],
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "0".to_owned(),
|
sig: "0".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +265,29 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_event_tag_match() -> Result<()> {
|
||||||
|
let event = simple_event();
|
||||||
|
assert!(!event
|
||||||
|
.generic_tag_val_intersect("e", &HashSet::from(["foo".to_owned(), "bar".to_owned()])));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_event_tag_match() -> Result<()> {
|
||||||
|
let mut event = simple_event();
|
||||||
|
event.tags = vec![vec!["e".to_owned(), "foo".to_owned()]];
|
||||||
|
event.build_index();
|
||||||
|
assert_eq!(
|
||||||
|
event.generic_tag_val_intersect(
|
||||||
|
"e",
|
||||||
|
&HashSet::from(["foo".to_owned(), "bar".to_owned()])
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_tags_serialize() -> Result<()> {
|
fn event_tags_serialize() -> Result<()> {
|
||||||
// serialize an event with tags to JSON string
|
// serialize an event with tags to JSON string
|
||||||
@@ -110,17 +312,51 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn event_deserialize() -> Result<()> {
|
fn event_deserialize() -> Result<()> {
|
||||||
let raw_json = r#"{"id":"1384757da583e6129ce831c3d7afc775a33a090578f888dd0d010328ad047d0c","pubkey":"bbbd9711d357df4f4e498841fd796535c95c8e751fa35355008a911c41265fca","created_at":1612650459,"kind":1,"tags":null,"content":"hello world","sig":"59d0cc47ab566e81f72fe5f430bcfb9b3c688cb0093d1e6daa49201c00d28ecc3651468b7938642869ed98c0f1b262998e49a05a6ed056c0d92b193f4e93bc21"}"#;
|
let raw_json = r#"{"id":"1384757da583e6129ce831c3d7afc775a33a090578f888dd0d010328ad047d0c","pubkey":"bbbd9711d357df4f4e498841fd796535c95c8e751fa35355008a911c41265fca","created_at":1612650459,"kind":1,"tags":null,"content":"hello world","sig":"59d0cc47ab566e81f72fe5f430bcfb9b3c688cb0093d1e6daa49201c00d28ecc3651468b7938642869ed98c0f1b262998e49a05a6ed056c0d92b193f4e93bc21"}"#;
|
||||||
// id: 1384757da583e6129ce831c3d7afc775a33a090578f888dd0d010328ad047d0c
|
|
||||||
// pubkey: bbbd9711d357df4f4e498841fd796535c95c8e751fa35355008a911c41265fca",
|
|
||||||
// created_at: 1612650459
|
|
||||||
// kind :1,
|
|
||||||
// tags":null,
|
|
||||||
// "content":"hello world",
|
|
||||||
// "sig":"59d0cc47ab566e81f72fe5f430bcfb9b3c688cb0093d1e6daa49201c00d28ecc3651468b7938642869ed98c0f1b262998e49a05a6ed056c0d92b193f4e93bc21"}]"#;
|
|
||||||
let e: Event = serde_json::from_str(raw_json)?;
|
let e: Event = serde_json::from_str(raw_json)?;
|
||||||
// assert that the kind is 1
|
|
||||||
assert_eq!(e.kind, 1);
|
assert_eq!(e.kind, 1);
|
||||||
assert_eq!(e.tags.len(), 0);
|
assert_eq!(e.tags.len(), 0);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_canonical() {
|
||||||
|
let e = Event {
|
||||||
|
id: "999".to_owned(),
|
||||||
|
pubkey: "012345".to_owned(),
|
||||||
|
created_at: 501234,
|
||||||
|
kind: 1,
|
||||||
|
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());
|
||||||
|
assert_eq!(c, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_canonical_with_tags() {
|
||||||
|
let e = Event {
|
||||||
|
id: "999".to_owned(),
|
||||||
|
pubkey: "012345".to_owned(),
|
||||||
|
created_at: 501234,
|
||||||
|
kind: 1,
|
||||||
|
tags: vec![
|
||||||
|
vec!["#e".to_owned(), "aoeu".to_owned()],
|
||||||
|
vec![
|
||||||
|
"#p".to_owned(),
|
||||||
|
"aaaa".to_owned(),
|
||||||
|
"ws://example.com".to_owned(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
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"]"###;
|
||||||
|
let expected = Some(expected_json.to_owned());
|
||||||
|
assert_eq!(c, expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
//! Utilities for searching hexadecimal
|
||||||
|
use crate::utils::is_hex;
|
||||||
|
use hex;
|
||||||
|
|
||||||
|
/// Types of hexadecimal queries.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
pub 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>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a string contains only f chars
|
||||||
|
fn is_all_fs(s: &str) -> bool {
|
||||||
|
s.chars().all(|x| x == 'f' || x == 'F')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the next hex sequence greater than the argument.
|
||||||
|
pub 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
#[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 = "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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
//! Relay metadata using NIP-11
|
||||||
|
/// Relay Info
|
||||||
|
use crate::config;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -1,5 +1,12 @@
|
|||||||
pub mod close;
|
pub mod close;
|
||||||
|
pub mod config;
|
||||||
|
pub mod conn;
|
||||||
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod proto;
|
pub mod hexrange;
|
||||||
|
pub mod info;
|
||||||
|
pub mod nip05;
|
||||||
|
pub mod schema;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
|
pub mod utils;
|
||||||
|
|||||||
+559
-46
@@ -1,66 +1,579 @@
|
|||||||
use log::info;
|
//! Server process
|
||||||
use nostr_rs_relay::proto::Proto;
|
use futures::SinkExt;
|
||||||
use std::{env, io::Error};
|
use futures::StreamExt;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
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::close::CloseCmd;
|
||||||
|
use nostr_rs_relay::config;
|
||||||
|
use nostr_rs_relay::conn;
|
||||||
|
use nostr_rs_relay::db;
|
||||||
|
use nostr_rs_relay::db::SubmittedEvent;
|
||||||
|
use nostr_rs_relay::error::{Error, Result};
|
||||||
|
use nostr_rs_relay::event::Event;
|
||||||
|
use nostr_rs_relay::event::EventCmd;
|
||||||
|
use nostr_rs_relay::info::RelayInfo;
|
||||||
|
use nostr_rs_relay::nip05;
|
||||||
|
use nostr_rs_relay::subscription::Subscription;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::env;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
use tokio::runtime::Builder;
|
use tokio::runtime::Builder;
|
||||||
|
use tokio::sync::broadcast::{self, Receiver, Sender};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio_tungstenite::WebSocketStream;
|
||||||
|
use tungstenite::error::Error as WsError;
|
||||||
|
use tungstenite::handshake;
|
||||||
|
use tungstenite::protocol::Message;
|
||||||
use tungstenite::protocol::WebSocketConfig;
|
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<SubmittedEvent>,
|
||||||
|
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> {
|
fn main() -> Result<(), Error> {
|
||||||
|
// setup logger
|
||||||
let _ = env_logger::try_init();
|
let _ = env_logger::try_init();
|
||||||
let addr = env::args()
|
// get database directory from args
|
||||||
.nth(1)
|
let args: Vec<String> = env::args().collect();
|
||||||
.unwrap_or_else(|| "0.0.0.0:8888".to_string());
|
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 settings = config::SETTINGS.read().unwrap();
|
||||||
|
trace!("Config: {:?}", settings);
|
||||||
|
// do some config validation.
|
||||||
|
if !Path::new(&settings.database.data_directory).is_dir() {
|
||||||
|
error!("Database directory does not exist");
|
||||||
|
return Err(Error::DatabaseDirError);
|
||||||
|
}
|
||||||
|
let addr = format!(
|
||||||
|
"{}:{}",
|
||||||
|
settings.network.address.trim(),
|
||||||
|
settings.network.port
|
||||||
|
);
|
||||||
|
let socket_addr = addr.parse().expect("listening address not valid");
|
||||||
|
// address whitelisting settings
|
||||||
|
if let Some(addr_whitelist) = &settings.authorization.pubkey_whitelist {
|
||||||
|
info!(
|
||||||
|
"Event publishing restricted to {} pubkey(s)",
|
||||||
|
addr_whitelist.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// check if NIP-05 enforced user verification is on
|
||||||
|
if settings.verified_users.is_active() {
|
||||||
|
info!(
|
||||||
|
"NIP-05 user verification mode:{:?}",
|
||||||
|
settings.verified_users.mode
|
||||||
|
);
|
||||||
|
if let Some(d) = settings.verified_users.verify_update_duration() {
|
||||||
|
info!("NIP-05 check user verification every: {:?}", d);
|
||||||
|
}
|
||||||
|
if let Some(d) = settings.verified_users.verify_expiration_duration() {
|
||||||
|
info!("NIP-05 user verification expires after: {:?}", d);
|
||||||
|
}
|
||||||
|
if let Some(wl) = &settings.verified_users.domain_whitelist {
|
||||||
|
info!("NIP-05 domain whitelist: {:?}", wl);
|
||||||
|
}
|
||||||
|
if let Some(bl) = &settings.verified_users.domain_blacklist {
|
||||||
|
info!("NIP-05 domain blacklist: {:?}", bl);
|
||||||
|
}
|
||||||
|
}
|
||||||
// configure tokio runtime
|
// configure tokio runtime
|
||||||
let rt = Builder::new_multi_thread()
|
let rt = Builder::new_multi_thread()
|
||||||
.worker_threads(2)
|
.enable_all()
|
||||||
.enable_io()
|
|
||||||
.thread_name("tokio-ws")
|
.thread_name("tokio-ws")
|
||||||
.on_thread_stop(|| {
|
|
||||||
info!("thread stopping");
|
|
||||||
})
|
|
||||||
.on_thread_start(|| {
|
|
||||||
info!("thread starting");
|
|
||||||
})
|
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// start tokio
|
// start tokio
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
// Create the event loop and TCP listener we'll accept connections on.
|
let settings = config::SETTINGS.read().unwrap();
|
||||||
let listener = TcpListener::bind(&addr).await.expect("Failed to bind");
|
info!("listening on: {}", socket_addr);
|
||||||
info!("Listening on: {}", addr);
|
// all client-submitted valid events are broadcast to every
|
||||||
while let Ok((stream, _)) = listener.accept().await {
|
// other client on this channel. This should be large enough
|
||||||
tokio::spawn(nostr_server(stream));
|
// to accomodate slower readers (messages are dropped if
|
||||||
|
// clients can not keep up).
|
||||||
|
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::<SubmittedEvent>(settings.limits.event_persist_buffer);
|
||||||
|
// establish a channel for letting all threads now about a
|
||||||
|
// requested server shutdown.
|
||||||
|
let (invoke_shutdown, shutdown_listen) = broadcast::channel::<()>(1);
|
||||||
|
// create a channel for sending any new metadata event. These
|
||||||
|
// will get processed relatively slowly (a potentially
|
||||||
|
// multi-second blocking HTTP call) on a single thread, so we
|
||||||
|
// buffer requests on the channel. No harm in dropping events
|
||||||
|
// here, since we are protecting against DoS. This can make
|
||||||
|
// it difficult to setup initial metadata in bulk, since
|
||||||
|
// overwhelming this will drop events and won't register
|
||||||
|
// metadata events.
|
||||||
|
let (metadata_tx, metadata_rx) = broadcast::channel::<Event>(4096);
|
||||||
|
// 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(),
|
||||||
|
metadata_tx.clone(),
|
||||||
|
shutdown_listen,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
info!("db writer created");
|
||||||
|
|
||||||
|
// create a nip-05 verifier thread
|
||||||
|
let verifier_opt = nip05::Verifier::new(metadata_rx, bcast_tx.clone());
|
||||||
|
if let Ok(mut v) = verifier_opt {
|
||||||
|
if settings.verified_users.is_active() {
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
info!("starting up NIP-05 verifier...");
|
||||||
|
v.run().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// // listen for ctrl-c interruupts
|
||||||
|
let ctrl_c_shutdown = invoke_shutdown.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.unwrap();
|
||||||
|
info!("shutting down due to SIGINT");
|
||||||
|
ctrl_c_shutdown.send(()).ok();
|
||||||
|
});
|
||||||
|
// build a connection pool for sqlite connections
|
||||||
|
let pool = db::build_pool(
|
||||||
|
"client query",
|
||||||
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||||
|
| rusqlite::OpenFlags::SQLITE_OPEN_SHARED_CACHE,
|
||||||
|
settings.database.min_conn,
|
||||||
|
settings.database.max_conn,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: Implement sending messages to all other clients; example:
|
/// Nostr protocol messages from a client
|
||||||
// https://github.com/snapview/tokio-tungstenite/blob/master/examples/server.rs
|
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||||
|
#[serde(untagged)]
|
||||||
// Wrap/Upgrade TCP connections in WebSocket Streams, and hand off to Nostr protocol handler.
|
pub enum NostrMessage {
|
||||||
async fn nostr_server(stream: TcpStream) {
|
/// An `EVENT` message
|
||||||
let addr = stream
|
EventMsg(EventCmd),
|
||||||
.peer_addr()
|
/// A `REQ` message
|
||||||
.expect("connected streams should have a peer address");
|
SubMsg(Subscription),
|
||||||
info!("Peer address: {}", addr);
|
/// A `CLOSE` message
|
||||||
let config = WebSocketConfig {
|
CloseMsg(CloseCmd),
|
||||||
max_send_queue: None,
|
}
|
||||||
max_message_size: Some(2 << 16), // 64K
|
|
||||||
max_frame_size: Some(2 << 16), // 64k
|
/// Convert Message to NostrMessage
|
||||||
accept_unmasked_frames: false, // follow the spec
|
fn convert_to_msg(msg: String) -> Result<NostrMessage> {
|
||||||
};
|
let config = config::SETTINGS.read().unwrap();
|
||||||
let conn = tokio_tungstenite::accept_async_with_config(stream, Some(config)).await;
|
let parsed_res: Result<NostrMessage> = serde_json::from_str(&msg).map_err(|e| e.into());
|
||||||
match conn {
|
match parsed_res {
|
||||||
Ok(ws_stream) => {
|
Ok(m) => {
|
||||||
info!("New WebSocket connection: {}", addr);
|
if let NostrMessage::EventMsg(_) = m {
|
||||||
// create a nostr protocol handler, and give it the stream
|
if let Some(max_size) = config.limits.max_event_bytes {
|
||||||
let mut proto = Proto::new(ws_stream);
|
// check length, ensure that some max size is set.
|
||||||
proto.process_client().await
|
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)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
}
|
||||||
println!("Error");
|
}
|
||||||
info!("Error during websocket handshake");
|
|
||||||
|
/// Handle new client connections. This runs through an event loop
|
||||||
|
/// for all client communication.
|
||||||
|
async fn nostr_server(
|
||||||
|
pool: db::SqlitePool,
|
||||||
|
mut ws_stream: WebSocketStream<Upgraded>,
|
||||||
|
broadcast: Sender<Event>,
|
||||||
|
event_tx: mpsc::Sender<SubmittedEvent>,
|
||||||
|
mut shutdown: Receiver<()>,
|
||||||
|
) {
|
||||||
|
// get a broadcast channel for clients to communicate on
|
||||||
|
let mut bcast_rx = broadcast.subscribe();
|
||||||
|
// Track internal client state
|
||||||
|
let mut conn = conn::ClientConn::new();
|
||||||
|
let cid = conn.get_client_prefix();
|
||||||
|
// Create a channel for receiving query results from the database.
|
||||||
|
// we will send out the tx handle to any query we generate.
|
||||||
|
let (query_tx, mut query_rx) = mpsc::channel::<db::QueryResult>(256);
|
||||||
|
// Create channel for receiving NOTICEs
|
||||||
|
let (notice_tx, mut notice_rx) = mpsc::channel::<String>(32);
|
||||||
|
|
||||||
|
// last time this client sent data (message, ping, etc.)
|
||||||
|
let mut last_message_time = Instant::now();
|
||||||
|
|
||||||
|
// ping interval (every 5 minutes)
|
||||||
|
let default_ping_dur = Duration::from_secs(300);
|
||||||
|
|
||||||
|
// disconnect after 20 minutes without a ping response or event.
|
||||||
|
let max_quiet_time = Duration::from_secs(60 * 20);
|
||||||
|
|
||||||
|
let start = tokio::time::Instant::now() + default_ping_dur;
|
||||||
|
let mut ping_interval = tokio::time::interval_at(start, default_ping_dur);
|
||||||
|
|
||||||
|
// maintain a hashmap of a oneshot channel for active subscriptions.
|
||||||
|
// when these subscriptions are cancelled, make a message
|
||||||
|
// available to the executing query so it knows to stop.
|
||||||
|
let mut running_queries: HashMap<String, oneshot::Sender<()>> = HashMap::new();
|
||||||
|
|
||||||
|
// for stats, keep track of how many events the client published,
|
||||||
|
// and how many it received from queries.
|
||||||
|
let mut client_published_event_count: usize = 0;
|
||||||
|
let mut client_received_event_count: usize = 0;
|
||||||
|
info!("new connection for client: {:?}", cid);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown.recv() => {
|
||||||
|
// server shutting down, exit loop
|
||||||
|
break;
|
||||||
|
},
|
||||||
|
_ = ping_interval.tick() => {
|
||||||
|
// check how long since we talked to client
|
||||||
|
// if it has been too long, disconnect
|
||||||
|
if last_message_time.elapsed() > max_quiet_time {
|
||||||
|
debug!("ending connection due to lack of client ping response");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Send a ping
|
||||||
|
ws_stream.send(Message::Ping(Vec::new())).await.ok();
|
||||||
|
},
|
||||||
|
Some(notice_msg) = notice_rx.recv() => {
|
||||||
|
let n = notice_msg.to_string().replace("\"", "");
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", n))).await.ok();
|
||||||
|
},
|
||||||
|
Some(query_result) = query_rx.recv() => {
|
||||||
|
// database informed us of a query result we asked for
|
||||||
|
client_received_event_count += 1;
|
||||||
|
// send a result
|
||||||
|
let subesc = query_result.sub_id.replace("\"", "");
|
||||||
|
let send_str = format!("[\"EVENT\",\"{}\",{}]", subesc, &query_result.event);
|
||||||
|
ws_stream.send(Message::Text(send_str)).await.ok();
|
||||||
|
},
|
||||||
|
// TODO: consider logging the LaggedRecv error
|
||||||
|
Ok(global_event) = bcast_rx.recv() => {
|
||||||
|
// an event has been broadcast to all clients
|
||||||
|
// first check if there is a subscription for this event.
|
||||||
|
let matching_subs = conn.get_matching_subscriptions(&global_event);
|
||||||
|
for s in matching_subs {
|
||||||
|
// TODO: serialize at broadcast time, instead of
|
||||||
|
// once for each consumer.
|
||||||
|
if let Ok(event_str) = serde_json::to_string(&global_event) {
|
||||||
|
debug!("sub match: client: {:?}, sub: {:?}, event: {:?}",
|
||||||
|
cid, s,
|
||||||
|
global_event.get_event_id_prefix());
|
||||||
|
// create an event response and send it
|
||||||
|
let subesc = s.replace("\"", "");
|
||||||
|
ws_stream.send(Message::Text(format!("[\"EVENT\",\"{}\",{}]", subesc, event_str))).await.ok();
|
||||||
|
//nostr_stream.send(res).await.ok();
|
||||||
|
} else {
|
||||||
|
warn!("could not serialize event {:?}", global_event.get_event_id_prefix());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ws_next = ws_stream.next() => {
|
||||||
|
// update most recent message time for client
|
||||||
|
last_message_time = Instant::now();
|
||||||
|
// Consume text messages from the client, parse into Nostr messages.
|
||||||
|
let nostr_msg = match ws_next {
|
||||||
|
Some(Ok(Message::Text(m))) => {
|
||||||
|
convert_to_msg(m)
|
||||||
|
},
|
||||||
|
Some(Ok(Message::Binary(_))) => {
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", "binary messages are not accepted"))).await.ok();
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {
|
||||||
|
// get a ping/pong, ignore. tungstenite will
|
||||||
|
// send responses automatically.
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
None |
|
||||||
|
Some(Ok(Message::Close(_))) |
|
||||||
|
Some(Err(WsError::AlreadyClosed)) |
|
||||||
|
Some(Err(WsError::ConnectionClosed)) |
|
||||||
|
Some(Err(WsError::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)))
|
||||||
|
=> {
|
||||||
|
debug!("websocket close from client: {:?}",cid);
|
||||||
|
break;
|
||||||
|
},
|
||||||
|
Some(Err(WsError::Io(e))) => {
|
||||||
|
// IO errors are considered fatal
|
||||||
|
warn!("IO error (client: {:?}): {:?}", cid, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
x => {
|
||||||
|
// default condition on error is to close the client connection
|
||||||
|
info!("unknown error (client: {:?}): {:?} (closing conn)", cid, x);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// convert ws_next into proto_next
|
||||||
|
match nostr_msg {
|
||||||
|
Ok(NostrMessage::EventMsg(ec)) => {
|
||||||
|
// An EventCmd needs to be validated to be converted into an Event
|
||||||
|
// handle each type of message
|
||||||
|
let parsed : Result<Event> = Result::<Event>::from(ec);
|
||||||
|
match parsed {
|
||||||
|
Ok(e) => {
|
||||||
|
let id_prefix:String = e.id.chars().take(8).collect();
|
||||||
|
debug!("successfully parsed/validated event: {:?} from client: {:?}", id_prefix, cid);
|
||||||
|
// Write this to the database.
|
||||||
|
let submit_event = SubmittedEvent { event: e.clone(), notice_tx: notice_tx.clone() };
|
||||||
|
event_tx.send(submit_event).await.ok();
|
||||||
|
client_published_event_count += 1;
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
info!("client {:?} sent an invalid event", cid);
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", "event was invalid"))).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(NostrMessage::SubMsg(s)) => {
|
||||||
|
debug!("client {} requesting a subscription", cid);
|
||||||
|
// subscription handling consists of:
|
||||||
|
// * registering the subscription so future events can be matched
|
||||||
|
// * making a channel to cancel to request later
|
||||||
|
// * sending a request for a SQL query
|
||||||
|
let (abandon_query_tx, abandon_query_rx) = oneshot::channel::<()>();
|
||||||
|
match conn.subscribe(s.clone()) {
|
||||||
|
Ok(()) => {
|
||||||
|
// when we insert, if there was a previous query running with the same name, cancel it.
|
||||||
|
if let Some(previous_query) = running_queries.insert(s.id.to_owned(), abandon_query_tx) {
|
||||||
|
previous_query.send(()).ok();
|
||||||
|
}
|
||||||
|
// start a database query
|
||||||
|
db::db_query(s, pool.clone(), query_tx.clone(), abandon_query_rx).await;
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
info!("Subscription error: {}", e);
|
||||||
|
let s = e.to_string().replace("\"", "");
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", s))).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(NostrMessage::CloseMsg(cc)) => {
|
||||||
|
// closing a request simply removes the subscription.
|
||||||
|
let parsed : Result<Close> = Result::<Close>::from(cc);
|
||||||
|
match parsed {
|
||||||
|
Ok(c) => {
|
||||||
|
// check if a query is currently
|
||||||
|
// running, and remove it if so.
|
||||||
|
let stop_tx = running_queries.remove(&c.id);
|
||||||
|
if let Some(tx) = stop_tx {
|
||||||
|
tx.send(()).ok();
|
||||||
|
}
|
||||||
|
// stop checking new events against
|
||||||
|
// the subscription
|
||||||
|
conn.unsubscribe(c);
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
info!("invalid command ignored");
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", "could not parse command"))).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(Error::ConnError) => {
|
||||||
|
debug!("got connection close/error, disconnecting client: {:?}",cid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(Error::EventMaxLengthError(s)) => {
|
||||||
|
info!("client {:?} sent event larger ({} bytes) than max size", cid, s);
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", "event exceeded max size"))).await.ok();
|
||||||
|
},
|
||||||
|
Err(Error::ProtoParseError) => {
|
||||||
|
info!("client {:?} sent event that could not be parsed", cid);
|
||||||
|
ws_stream.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", "could not parse command"))).await.ok();
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
info!("got non-fatal error from client: {:?}, error: {:?}", cid, e);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
// connection cleanup - ensure any still running queries are terminated.
|
||||||
|
for (_, stop_tx) in running_queries.into_iter() {
|
||||||
|
stop_tx.send(()).ok();
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"stopping connection for client: {:?} (client sent {} event(s), received {})",
|
||||||
|
cid, client_published_event_count, client_received_event_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+818
@@ -0,0 +1,818 @@
|
|||||||
|
//! User verification using NIP-05 names
|
||||||
|
//!
|
||||||
|
//! NIP-05 defines a mechanism for authors to associate an internet
|
||||||
|
//! address with their public key, in metadata events. This module
|
||||||
|
//! consumes a stream of metadata events, and keeps a database table
|
||||||
|
//! updated with the current NIP-05 verification status.
|
||||||
|
use crate::config::SETTINGS;
|
||||||
|
use crate::db;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::event::Event;
|
||||||
|
use crate::utils::unix_time;
|
||||||
|
use hyper::body::HttpBody;
|
||||||
|
use hyper::client::connect::HttpConnector;
|
||||||
|
use hyper::Client;
|
||||||
|
use hyper_tls::HttpsConnector;
|
||||||
|
use log::*;
|
||||||
|
use rand::Rng;
|
||||||
|
use rusqlite::params;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use tokio::time::Interval;
|
||||||
|
|
||||||
|
/// NIP-05 verifier state
|
||||||
|
pub struct Verifier {
|
||||||
|
/// Metadata events for us to inspect
|
||||||
|
metadata_rx: tokio::sync::broadcast::Receiver<Event>,
|
||||||
|
/// Newly validated events get written and then broadcast on this channel to subscribers
|
||||||
|
event_tx: tokio::sync::broadcast::Sender<Event>,
|
||||||
|
/// SQLite read query pool
|
||||||
|
read_pool: db::SqlitePool,
|
||||||
|
/// SQLite write query pool
|
||||||
|
write_pool: db::SqlitePool,
|
||||||
|
/// HTTP client
|
||||||
|
client: hyper::Client<HttpsConnector<HttpConnector>, hyper::Body>,
|
||||||
|
/// After all accounts are updated, wait this long before checking again.
|
||||||
|
wait_after_finish: Duration,
|
||||||
|
/// Minimum amount of time between HTTP queries
|
||||||
|
http_wait_duration: Duration,
|
||||||
|
/// Interval for updating verification records
|
||||||
|
reverify_interval: Interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-05 identifier is a local part and domain.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
pub struct Nip05Name {
|
||||||
|
local: String,
|
||||||
|
domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nip05Name {
|
||||||
|
/// Does this name represent the entire domain?
|
||||||
|
pub fn is_domain_only(&self) -> bool {
|
||||||
|
self.local == "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the URL to query for verification
|
||||||
|
fn to_url(&self) -> Option<http::Uri> {
|
||||||
|
format!(
|
||||||
|
"https://{}/.well-known/nostr.json?name={}",
|
||||||
|
self.domain, self.local
|
||||||
|
)
|
||||||
|
.parse::<http::Uri>()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing Nip05Names from strings
|
||||||
|
impl std::convert::TryFrom<&str> for Nip05Name {
|
||||||
|
type Error = Error;
|
||||||
|
fn try_from(inet: &str) -> Result<Self, Self::Error> {
|
||||||
|
// break full name at the @ boundary.
|
||||||
|
let components: Vec<&str> = inet.split('@').collect();
|
||||||
|
if components.len() != 2 {
|
||||||
|
Err(Error::CustomError("too many/few components".to_owned()))
|
||||||
|
} else {
|
||||||
|
// check if local name is valid
|
||||||
|
let local = components[0];
|
||||||
|
let domain = components[1];
|
||||||
|
if local
|
||||||
|
.chars()
|
||||||
|
.all(|x| x.is_alphanumeric() || x == '_' || x == '-' || x == '.')
|
||||||
|
{
|
||||||
|
if domain
|
||||||
|
.chars()
|
||||||
|
.all(|x| x.is_alphanumeric() || x == '-' || x == '.')
|
||||||
|
{
|
||||||
|
Ok(Nip05Name {
|
||||||
|
local: local.to_owned(),
|
||||||
|
domain: domain.to_owned(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(Error::CustomError(
|
||||||
|
"invalid character in domain part".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Error::CustomError(
|
||||||
|
"invalid character in local part".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Nip05Name {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}@{}", self.local, self.domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current time, with a slight foward jitter in seconds
|
||||||
|
fn now_jitter(sec: u64) -> u64 {
|
||||||
|
// random time between now, and 10min in future.
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let jitter_amount = rng.gen_range(0..sec);
|
||||||
|
let now = unix_time();
|
||||||
|
now.saturating_add(jitter_amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the specified username and address are present and match in this response body
|
||||||
|
fn body_contains_user(username: &str, address: &str, bytes: hyper::body::Bytes) -> Result<bool> {
|
||||||
|
// convert the body into json
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||||
|
// ensure we have a names object.
|
||||||
|
let names_map = body
|
||||||
|
.as_object()
|
||||||
|
.and_then(|x| x.get("names"))
|
||||||
|
.and_then(|x| x.as_object())
|
||||||
|
.ok_or_else(|| Error::CustomError("not a map".to_owned()))?;
|
||||||
|
// get the pubkey for the requested user
|
||||||
|
let check_name = names_map.get(username).and_then(|x| x.as_str());
|
||||||
|
// ensure the address is a match
|
||||||
|
Ok(check_name.map(|x| x == address).unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verifier {
|
||||||
|
pub fn new(
|
||||||
|
metadata_rx: tokio::sync::broadcast::Receiver<Event>,
|
||||||
|
event_tx: tokio::sync::broadcast::Sender<Event>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
info!("creating NIP-05 verifier");
|
||||||
|
// build a database connection for reading and writing.
|
||||||
|
let write_pool = db::build_pool(
|
||||||
|
"nip05 writer",
|
||||||
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE,
|
||||||
|
1, // min conns
|
||||||
|
4, // max conns
|
||||||
|
true, // wait for DB
|
||||||
|
);
|
||||||
|
let read_pool = db::build_pool(
|
||||||
|
"nip05 reader",
|
||||||
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||||
|
1, // min conns
|
||||||
|
8, // max conns
|
||||||
|
true, // wait for DB
|
||||||
|
);
|
||||||
|
// setup hyper client
|
||||||
|
let https = HttpsConnector::new();
|
||||||
|
let client = Client::builder().build::<_, hyper::Body>(https);
|
||||||
|
|
||||||
|
// After all accounts have been re-verified, don't check again
|
||||||
|
// for this long.
|
||||||
|
let wait_after_finish = Duration::from_secs(60 * 10);
|
||||||
|
// when we have an active queue of accounts to validate, we
|
||||||
|
// will wait this duration between HTTP requests.
|
||||||
|
let http_wait_duration = Duration::from_secs(1);
|
||||||
|
// setup initial interval for re-verification. If we find
|
||||||
|
// there is no work to be done, it will be reset to a longer
|
||||||
|
// duration.
|
||||||
|
let reverify_interval = tokio::time::interval(http_wait_duration);
|
||||||
|
Ok(Verifier {
|
||||||
|
metadata_rx,
|
||||||
|
event_tx,
|
||||||
|
read_pool,
|
||||||
|
write_pool,
|
||||||
|
client,
|
||||||
|
wait_after_finish,
|
||||||
|
http_wait_duration,
|
||||||
|
reverify_interval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform web verification against a NIP-05 name and address.
|
||||||
|
pub async fn get_web_verification(
|
||||||
|
&mut self,
|
||||||
|
nip: &Nip05Name,
|
||||||
|
pubkey: &str,
|
||||||
|
) -> UserWebVerificationStatus {
|
||||||
|
self.get_web_verification_res(nip, pubkey)
|
||||||
|
.await
|
||||||
|
.unwrap_or(UserWebVerificationStatus::Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform web verification against an `Event` (must be metadata).
|
||||||
|
pub async fn get_web_verification_from_event(
|
||||||
|
&mut self,
|
||||||
|
e: &Event,
|
||||||
|
) -> UserWebVerificationStatus {
|
||||||
|
let nip_parse = e.get_nip05_addr();
|
||||||
|
if let Some(nip) = nip_parse {
|
||||||
|
self.get_web_verification_res(&nip, &e.pubkey)
|
||||||
|
.await
|
||||||
|
.unwrap_or(UserWebVerificationStatus::Unknown)
|
||||||
|
} else {
|
||||||
|
UserWebVerificationStatus::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform web verification, with a `Result` return.
|
||||||
|
async fn get_web_verification_res(
|
||||||
|
&mut self,
|
||||||
|
nip: &Nip05Name,
|
||||||
|
pubkey: &str,
|
||||||
|
) -> Result<UserWebVerificationStatus> {
|
||||||
|
// determine if this domain should be checked
|
||||||
|
if !is_domain_allowed(&nip.domain) {
|
||||||
|
return Ok(UserWebVerificationStatus::DomainNotAllowed);
|
||||||
|
}
|
||||||
|
let url = nip
|
||||||
|
.to_url()
|
||||||
|
.ok_or_else(|| Error::CustomError("invalid NIP-05 URL".to_owned()))?;
|
||||||
|
let req = hyper::Request::builder()
|
||||||
|
.method(hyper::Method::GET)
|
||||||
|
.uri(url)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header(
|
||||||
|
"User-Agent",
|
||||||
|
format!(
|
||||||
|
"nostr-rs-relay/{} NIP-05 Verifier",
|
||||||
|
crate::info::CARGO_PKG_VERSION.unwrap()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.body(hyper::Body::empty())
|
||||||
|
.expect("request builder");
|
||||||
|
|
||||||
|
let response_fut = self.client.request(req);
|
||||||
|
|
||||||
|
// HTTP request with timeout
|
||||||
|
match tokio::time::timeout(Duration::from_secs(5), response_fut).await {
|
||||||
|
Ok(response_res) => {
|
||||||
|
let response = response_res?;
|
||||||
|
// limit size of verification document to 1MB.
|
||||||
|
const MAX_ALLOWED_RESPONSE_SIZE: u64 = 1024 * 1024;
|
||||||
|
// determine content length from response
|
||||||
|
let response_content_length = match response.body().size_hint().upper() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => MAX_ALLOWED_RESPONSE_SIZE + 1, // reject missing content length
|
||||||
|
};
|
||||||
|
// TODO: test how hyper handles the client providing an inaccurate content-length.
|
||||||
|
if response_content_length <= MAX_ALLOWED_RESPONSE_SIZE {
|
||||||
|
let (parts, body) = response.into_parts();
|
||||||
|
// TODO: consider redirects
|
||||||
|
if parts.status == http::StatusCode::OK {
|
||||||
|
// parse body, determine if the username / key / address is present
|
||||||
|
let body_bytes = hyper::body::to_bytes(body).await?;
|
||||||
|
let body_matches = body_contains_user(&nip.local, pubkey, body_bytes)?;
|
||||||
|
if body_matches {
|
||||||
|
return Ok(UserWebVerificationStatus::Verified);
|
||||||
|
} else {
|
||||||
|
// successful response, parsed as a nip-05
|
||||||
|
// document, but this name/pubkey was not
|
||||||
|
// present.
|
||||||
|
return Ok(UserWebVerificationStatus::Unverified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
"content length missing or exceeded limits for account: {:?}",
|
||||||
|
nip.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
info!("timeout verifying account {:?}", nip);
|
||||||
|
return Ok(UserWebVerificationStatus::Unknown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(UserWebVerificationStatus::Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform NIP-05 verifier tasks.
|
||||||
|
pub async fn run(&mut self) {
|
||||||
|
// use this to schedule periodic re-validation tasks
|
||||||
|
// run a loop, restarting on failure
|
||||||
|
loop {
|
||||||
|
let res = self.run_internal().await;
|
||||||
|
if let Err(e) = res {
|
||||||
|
info!("error in verifier: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal select loop for performing verification
|
||||||
|
async fn run_internal(&mut self) -> Result<()> {
|
||||||
|
tokio::select! {
|
||||||
|
m = self.metadata_rx.recv() => {
|
||||||
|
match m {
|
||||||
|
Ok(e) => {
|
||||||
|
if let Some(naddr) = e.get_nip05_addr() {
|
||||||
|
info!("got metadata event for ({:?},{:?})", naddr.to_string() ,e.get_author_prefix());
|
||||||
|
// Process a new author, checking if they are verified:
|
||||||
|
let check_verified = get_latest_user_verification(self.read_pool.get().expect("could not get connection"), &e.pubkey).await;
|
||||||
|
// ensure the event we got is more recent than the one we have, otherwise we can ignore it.
|
||||||
|
if let Ok(last_check) = check_verified {
|
||||||
|
if e.created_at <= last_check.event_created {
|
||||||
|
// this metadata is from the same author as an existing verification.
|
||||||
|
// it is older than what we have, so we can ignore it.
|
||||||
|
debug!("received older metadata event for author {:?}", e.get_author_prefix());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// old, or no existing record for this user. In either case, we just create a new one.
|
||||||
|
let start = Instant::now();
|
||||||
|
let v = self.get_web_verification_from_event(&e).await;
|
||||||
|
info!(
|
||||||
|
"checked name {:?}, result: {:?}, in: {:?}",
|
||||||
|
naddr.to_string(),
|
||||||
|
v,
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
// sleep to limit how frequently we make HTTP requests for new metadata events. This should limit us to 4 req/sec.
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
// if this user was verified, we need to write the
|
||||||
|
// record, persist the event, and broadcast.
|
||||||
|
if let UserWebVerificationStatus::Verified = v {
|
||||||
|
self.create_new_verified_user(&naddr.to_string(), &e).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(c)) => {
|
||||||
|
warn!("incoming metadata events overwhelmed buffer, {} events dropped",c);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
info!("metadata broadcast channel closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ = self.reverify_interval.tick() => {
|
||||||
|
// check and see if there is an old account that needs
|
||||||
|
// to be reverified
|
||||||
|
self.do_reverify().await?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverify the oldest user verification record.
|
||||||
|
async fn do_reverify(&mut self) -> Result<()> {
|
||||||
|
let reverify_setting;
|
||||||
|
let max_failures;
|
||||||
|
{
|
||||||
|
// this block prevents a read handle to settings being
|
||||||
|
// captured by the async DB call (guard is not Send)
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
reverify_setting = settings.verified_users.verify_update_frequency_duration;
|
||||||
|
max_failures = settings.verified_users.max_consecutive_failures;
|
||||||
|
}
|
||||||
|
// get from settings, but default to 6hrs between re-checking an account
|
||||||
|
let reverify_dur = reverify_setting.unwrap_or_else(|| Duration::from_secs(60 * 60 * 6));
|
||||||
|
// find all verification records that have success or failure OLDER than the reverify_dur.
|
||||||
|
let now = SystemTime::now();
|
||||||
|
let earliest = now - reverify_dur;
|
||||||
|
let earliest_epoch = earliest
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.map(|x| x.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let vr = get_oldest_user_verification(self.read_pool.get()?, earliest_epoch).await;
|
||||||
|
match vr {
|
||||||
|
Ok(ref v) => {
|
||||||
|
let new_status = self.get_web_verification(&v.name, &v.address).await;
|
||||||
|
match new_status {
|
||||||
|
UserWebVerificationStatus::Verified => {
|
||||||
|
// freshly verified account, update the
|
||||||
|
// timestamp.
|
||||||
|
self.update_verification_record(self.write_pool.get()?, v)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
UserWebVerificationStatus::DomainNotAllowed
|
||||||
|
| UserWebVerificationStatus::Unknown => {
|
||||||
|
// server may be offline, or temporarily
|
||||||
|
// blocked by the config file. Note the
|
||||||
|
// failure so we can process something
|
||||||
|
// else.
|
||||||
|
|
||||||
|
// have we had enough failures to give up?
|
||||||
|
if v.failure_count >= max_failures as u64 {
|
||||||
|
info!(
|
||||||
|
"giving up on verifying {:?} after {} failures",
|
||||||
|
v.name, v.failure_count
|
||||||
|
);
|
||||||
|
self.delete_verification_record(self.write_pool.get()?, v)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
// record normal failure, incrementing failure count
|
||||||
|
self.fail_verification_record(self.write_pool.get()?, v)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UserWebVerificationStatus::Unverified => {
|
||||||
|
// domain has removed the verification, drop
|
||||||
|
// the record on our side.
|
||||||
|
self.delete_verification_record(self.write_pool.get()?, v)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::SqlError(rusqlite::Error::QueryReturnedNoRows)) => {
|
||||||
|
// No users need verification. Reset the interval to
|
||||||
|
// the next verification attempt.
|
||||||
|
let start = tokio::time::Instant::now() + self.wait_after_finish;
|
||||||
|
self.reverify_interval = tokio::time::interval_at(start, self.http_wait_duration);
|
||||||
|
}
|
||||||
|
Err(ref e) => {
|
||||||
|
warn!(
|
||||||
|
"Error when checking for NIP-05 verification records: {:?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the verification timestamp on a VerificationRecord
|
||||||
|
pub async fn update_verification_record(
|
||||||
|
&mut self,
|
||||||
|
mut conn: db::PooledConnection,
|
||||||
|
vr: &VerificationRecord,
|
||||||
|
) -> Result<()> {
|
||||||
|
let vr_id = vr.rowid;
|
||||||
|
let vr_str = vr.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// add some jitter to the verification to prevent everything from stacking up together.
|
||||||
|
let verif_time = now_jitter(600);
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
// update verification time and reset any failure count
|
||||||
|
let query =
|
||||||
|
"UPDATE user_verification SET verified_at=?, failure_count=0 WHERE id=?";
|
||||||
|
let mut stmt = tx.prepare(query)?;
|
||||||
|
stmt.execute(params![verif_time, vr_id])?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
info!("verification updated for {}", vr_str);
|
||||||
|
let ok: Result<()> = Ok(());
|
||||||
|
ok
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
/// Reset the failure timestamp on a VerificationRecord
|
||||||
|
pub async fn fail_verification_record(
|
||||||
|
&mut self,
|
||||||
|
mut conn: db::PooledConnection,
|
||||||
|
vr: &VerificationRecord,
|
||||||
|
) -> Result<()> {
|
||||||
|
let vr_id = vr.rowid;
|
||||||
|
let vr_str = vr.to_string();
|
||||||
|
let fail_count = vr.failure_count.saturating_add(1);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// add some jitter to the verification to prevent everything from stacking up together.
|
||||||
|
let fail_time = now_jitter(600);
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
let query = "UPDATE user_verification SET failed_at=?, failure_count=? WHERE id=?";
|
||||||
|
let mut stmt = tx.prepare(query)?;
|
||||||
|
stmt.execute(params![fail_time, fail_count, vr_id])?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
info!("verification failed for {}", vr_str);
|
||||||
|
let ok: Result<()> = Ok(());
|
||||||
|
ok
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
/// Delete a VerificationRecord that is no longer valid
|
||||||
|
pub async fn delete_verification_record(
|
||||||
|
&mut self,
|
||||||
|
mut conn: db::PooledConnection,
|
||||||
|
vr: &VerificationRecord,
|
||||||
|
) -> Result<()> {
|
||||||
|
let vr_id = vr.rowid;
|
||||||
|
let vr_str = vr.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
let query = "DELETE FROM user_verification WHERE id=?;";
|
||||||
|
let mut stmt = tx.prepare(query)?;
|
||||||
|
stmt.execute(params![vr_id])?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
info!("verification rescinded for {}", vr_str);
|
||||||
|
let ok: Result<()> = Ok(());
|
||||||
|
ok
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist an event, create a verification record, and broadcast.
|
||||||
|
// TODO: have more event-writing logic handled in the db module.
|
||||||
|
// Right now, these events avoid the rate limit. That is
|
||||||
|
// acceptable since as soon as the user is registered, this path
|
||||||
|
// is no longer used.
|
||||||
|
// TODO: refactor these into spawn_blocking
|
||||||
|
// calls to get them off the async executors.
|
||||||
|
async fn create_new_verified_user(&mut self, name: &str, event: &Event) -> Result<()> {
|
||||||
|
let start = Instant::now();
|
||||||
|
// we should only do this if we are enabled. if we are
|
||||||
|
// disabled/passive, the event has already been persisted.
|
||||||
|
let should_write_event;
|
||||||
|
{
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
should_write_event = settings.verified_users.is_enabled()
|
||||||
|
}
|
||||||
|
if should_write_event {
|
||||||
|
match db::write_event(&mut self.write_pool.get()?, event) {
|
||||||
|
Ok(updated) => {
|
||||||
|
if updated != 0 {
|
||||||
|
info!(
|
||||||
|
"persisted event: {:?} in {:?}",
|
||||||
|
event.get_event_id_prefix(),
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
self.event_tx.send(event.clone()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("event insert failed: {:?}", err);
|
||||||
|
if let Error::SqlError(r) = err {
|
||||||
|
warn!("because: : {:?}", r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// write the verification record
|
||||||
|
save_verification_record(self.write_pool.get()?, event, name).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of checking user's verification status against DNS/HTTP.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
pub enum UserWebVerificationStatus {
|
||||||
|
Verified, // user is verified, as of now.
|
||||||
|
DomainNotAllowed, // domain blacklist or whitelist denied us from attempting a verification
|
||||||
|
Unknown, // user's status could not be determined (timeout, server error)
|
||||||
|
Unverified, // user's status is not verified (successful check, name / addr do not match)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-05 verification record.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
// Basic information for a verification event. Gives us all we need to assert a NIP-05 address is good.
|
||||||
|
pub struct VerificationRecord {
|
||||||
|
pub rowid: u64, // database row for this verification event
|
||||||
|
pub name: Nip05Name, // address being verified
|
||||||
|
pub address: String, // pubkey
|
||||||
|
pub event: String, // event ID hash providing the verification
|
||||||
|
pub event_created: u64, // when the metadata event was published
|
||||||
|
pub last_success: Option<u64>, // the most recent time a verification was provided. None if verification under this name has never succeeded.
|
||||||
|
pub last_failure: Option<u64>, // the most recent time verification was attempted, but could not be completed.
|
||||||
|
pub failure_count: u64, // how many consecutive failures have been observed.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check with settings to determine if a given domain is allowed to
|
||||||
|
/// publish.
|
||||||
|
pub fn is_domain_allowed(domain: &str) -> bool {
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
// if there is a whitelist, domain must be present in it.
|
||||||
|
if let Some(wl) = &settings.verified_users.domain_whitelist {
|
||||||
|
// workaround for Vec contains not accepting &str
|
||||||
|
return wl.iter().any(|x| x == domain);
|
||||||
|
}
|
||||||
|
// otherwise, check that user is not in the blacklist
|
||||||
|
if let Some(bl) = &settings.verified_users.domain_blacklist {
|
||||||
|
return !bl.iter().any(|x| x == domain);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VerificationRecord {
|
||||||
|
/// Check if the record is recent enough to be considered valid,
|
||||||
|
/// and the domain is allowed.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
let settings = SETTINGS.read().unwrap();
|
||||||
|
// how long a verification record is good for
|
||||||
|
let nip05_expiration = &settings.verified_users.verify_expiration_duration;
|
||||||
|
if let Some(e) = nip05_expiration {
|
||||||
|
if !self.is_current(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// check domains
|
||||||
|
is_domain_allowed(&self.name.domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this record has been validated since the given
|
||||||
|
/// duration.
|
||||||
|
fn is_current(&self, d: &Duration) -> bool {
|
||||||
|
match self.last_success {
|
||||||
|
Some(s) => {
|
||||||
|
// current time - duration
|
||||||
|
let now = SystemTime::now();
|
||||||
|
let cutoff = now - *d;
|
||||||
|
let cutoff_epoch = cutoff
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.map(|x| x.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
s > cutoff_epoch
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for VerificationRecord {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"({:?},{:?})",
|
||||||
|
self.name.to_string(),
|
||||||
|
self.address.chars().take(8).collect::<String>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new verification record based on an event
|
||||||
|
pub async fn save_verification_record(
|
||||||
|
mut conn: db::PooledConnection,
|
||||||
|
event: &Event,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let e = hex::decode(&event.id).ok();
|
||||||
|
let n = name.to_owned();
|
||||||
|
let a_prefix = event.get_author_prefix();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
// if we create a /new/ one, we should get rid of any old ones. or group the new ones by name and only consider the latest.
|
||||||
|
let query = "INSERT INTO user_verification (metadata_event, name, verified_at) VALUES ((SELECT id from event WHERE event_hash=?), ?, strftime('%s','now'));";
|
||||||
|
let mut stmt = tx.prepare(query)?;
|
||||||
|
stmt.execute(params![e, n])?;
|
||||||
|
// get the row ID
|
||||||
|
let v_id = tx.last_insert_rowid();
|
||||||
|
// delete everything else by this name
|
||||||
|
let del_query = "DELETE FROM user_verification WHERE name = ? AND id != ?;";
|
||||||
|
let mut del_stmt = tx.prepare(del_query)?;
|
||||||
|
let count = del_stmt.execute(params![n,v_id])?;
|
||||||
|
if count > 0 {
|
||||||
|
info!("removed {} old verification records for ({:?},{:?})", count, n, a_prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
info!("saved new verification record for ({:?},{:?})", n, a_prefix);
|
||||||
|
let ok: Result<()> = Ok(());
|
||||||
|
ok
|
||||||
|
}).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the most recent verification record for a given pubkey (async).
|
||||||
|
pub async fn get_latest_user_verification(
|
||||||
|
conn: db::PooledConnection,
|
||||||
|
pubkey: &str,
|
||||||
|
) -> Result<VerificationRecord> {
|
||||||
|
let p = pubkey.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || query_latest_user_verification(conn, p)).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query database for the latest verification record for a given pubkey.
|
||||||
|
pub fn query_latest_user_verification(
|
||||||
|
mut conn: db::PooledConnection,
|
||||||
|
pubkey: String,
|
||||||
|
) -> Result<VerificationRecord> {
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let query = "SELECT v.id, v.name, e.event_hash, e.created_at, v.verified_at, v.failed_at, v.failure_count FROM user_verification v LEFT JOIN event e ON e.id=v.metadata_event WHERE e.author=? ORDER BY e.created_at DESC, v.verified_at DESC, v.failed_at DESC LIMIT 1;";
|
||||||
|
let mut stmt = tx.prepare_cached(query)?;
|
||||||
|
let fields = stmt.query_row(params![hex::decode(&pubkey).ok()], |r| {
|
||||||
|
let rowid: u64 = r.get(0)?;
|
||||||
|
let rowname: String = r.get(1)?;
|
||||||
|
let eventid: Vec<u8> = r.get(2)?;
|
||||||
|
let created_at: u64 = r.get(3)?;
|
||||||
|
// create a tuple since we can't throw non-rusqlite errors in this closure
|
||||||
|
Ok((
|
||||||
|
rowid,
|
||||||
|
rowname,
|
||||||
|
eventid,
|
||||||
|
created_at,
|
||||||
|
r.get(4).ok(),
|
||||||
|
r.get(5).ok(),
|
||||||
|
r.get(6)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(VerificationRecord {
|
||||||
|
rowid: fields.0,
|
||||||
|
name: Nip05Name::try_from(&fields.1[..])?,
|
||||||
|
address: pubkey,
|
||||||
|
event: hex::encode(fields.2),
|
||||||
|
event_created: fields.3,
|
||||||
|
last_success: fields.4,
|
||||||
|
last_failure: fields.5,
|
||||||
|
failure_count: fields.6,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the oldest user verification (async)
|
||||||
|
pub async fn get_oldest_user_verification(
|
||||||
|
conn: db::PooledConnection,
|
||||||
|
earliest: u64,
|
||||||
|
) -> Result<VerificationRecord> {
|
||||||
|
let res =
|
||||||
|
tokio::task::spawn_blocking(move || query_oldest_user_verification(conn, earliest)).await?;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_oldest_user_verification(
|
||||||
|
mut conn: db::PooledConnection,
|
||||||
|
earliest: u64,
|
||||||
|
) -> Result<VerificationRecord> {
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let query = "SELECT v.id, v.name, e.event_hash, e.author, e.created_at, v.verified_at, v.failed_at, v.failure_count FROM user_verification v LEFT JOIN event e ON e.id=v.metadata_event WHERE (v.verified_at < ? OR v.verified_at IS NULL) AND (v.failed_at < ? OR v.failed_at IS NULL) ORDER BY v.verified_at ASC, v.failed_at ASC LIMIT 1;";
|
||||||
|
let mut stmt = tx.prepare_cached(query)?;
|
||||||
|
let fields = stmt.query_row(params![earliest, earliest], |r| {
|
||||||
|
let rowid: u64 = r.get(0)?;
|
||||||
|
let rowname: String = r.get(1)?;
|
||||||
|
let eventid: Vec<u8> = r.get(2)?;
|
||||||
|
let pubkey: Vec<u8> = r.get(3)?;
|
||||||
|
let created_at: u64 = r.get(4)?;
|
||||||
|
// create a tuple since we can't throw non-rusqlite errors in this closure
|
||||||
|
Ok((
|
||||||
|
rowid,
|
||||||
|
rowname,
|
||||||
|
eventid,
|
||||||
|
pubkey,
|
||||||
|
created_at,
|
||||||
|
r.get(5).ok(),
|
||||||
|
r.get(6).ok(),
|
||||||
|
r.get(7)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let vr = VerificationRecord {
|
||||||
|
rowid: fields.0,
|
||||||
|
name: Nip05Name::try_from(&fields.1[..])?,
|
||||||
|
address: hex::encode(fields.3),
|
||||||
|
event: hex::encode(fields.2),
|
||||||
|
event_created: fields.4,
|
||||||
|
last_success: fields.5,
|
||||||
|
last_failure: fields.6,
|
||||||
|
failure_count: fields.7,
|
||||||
|
};
|
||||||
|
Ok(vr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_from_inet() {
|
||||||
|
let addr = "bob@example.com";
|
||||||
|
let parsed = Nip05Name::try_from(addr);
|
||||||
|
assert!(!parsed.is_err());
|
||||||
|
let v = parsed.unwrap();
|
||||||
|
assert_eq!(v.local, "bob");
|
||||||
|
assert_eq!(v.domain, "example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_enough_sep() {
|
||||||
|
let addr = "bob_example.com";
|
||||||
|
let parsed = Nip05Name::try_from(addr);
|
||||||
|
assert!(parsed.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_many_sep() {
|
||||||
|
let addr = "foo@bob@example.com";
|
||||||
|
let parsed = Nip05Name::try_from(addr);
|
||||||
|
assert!(parsed.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_local_name() {
|
||||||
|
// non-permitted ascii chars
|
||||||
|
assert!(Nip05Name::try_from("foo!@example.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("foo @example.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from(" foo@example.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("f oo@example.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("foo<@example.com").is_err());
|
||||||
|
// unicode dash
|
||||||
|
assert!(Nip05Name::try_from("foo‐bar@example.com").is_err());
|
||||||
|
// emoji
|
||||||
|
assert!(Nip05Name::try_from("foo😭bar@example.com").is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn invalid_domain_name() {
|
||||||
|
// non-permitted ascii chars
|
||||||
|
assert!(Nip05Name::try_from("foo@examp!e.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("foo@ example.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("foo@exa mple.com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("foo@example .com").is_err());
|
||||||
|
assert!(Nip05Name::try_from("foo@exa<mple.com").is_err());
|
||||||
|
// unicode dash
|
||||||
|
assert!(Nip05Name::try_from("foobar@exa‐mple.com").is_err());
|
||||||
|
// emoji
|
||||||
|
assert!(Nip05Name::try_from("foobar@ex😭ample.com").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_url() {
|
||||||
|
let nip = Nip05Name::try_from("foobar@example.com").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
nip.to_url(),
|
||||||
|
Some(
|
||||||
|
"https://example.com/.well-known/nostr.json?name=foobar"
|
||||||
|
.parse()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
-234
@@ -1,234 +0,0 @@
|
|||||||
use crate::close::Close;
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use crate::event::Event;
|
|
||||||
use crate::subscription::Subscription;
|
|
||||||
use futures::SinkExt;
|
|
||||||
use tungstenite::error::Error::*;
|
|
||||||
use tungstenite::protocol::frame::coding::CloseCode;
|
|
||||||
use tungstenite::protocol::frame::CloseFrame;
|
|
||||||
use tungstenite::protocol::Message;
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use tokio_tungstenite::WebSocketStream;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
// A protocol handler/helper. Use one per client.
|
|
||||||
pub struct Proto {
|
|
||||||
client_id: Uuid,
|
|
||||||
// current set of subscriptions
|
|
||||||
subscriptions: HashMap<String, Subscription>,
|
|
||||||
// websocket
|
|
||||||
stream: WebSocketStream<TcpStream>,
|
|
||||||
max_subs: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_SUBSCRIPTION_ID_LEN: usize = 256;
|
|
||||||
|
|
||||||
impl Proto {
|
|
||||||
pub fn new(stream: WebSocketStream<TcpStream>) -> Self {
|
|
||||||
let p = Proto {
|
|
||||||
client_id: Uuid::new_v4(),
|
|
||||||
subscriptions: HashMap::new(),
|
|
||||||
max_subs: 128,
|
|
||||||
stream: stream,
|
|
||||||
};
|
|
||||||
debug!("New client: {:?}", p.client_id);
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn process_client(&mut self) {
|
|
||||||
while let Some(mes_res) = self.stream.next().await {
|
|
||||||
match mes_res {
|
|
||||||
Ok(Message::Text(cmd)) => {
|
|
||||||
info!("Message received");
|
|
||||||
debug!("Message: {}", cmd);
|
|
||||||
let proto_error = self.process_message(cmd);
|
|
||||||
match proto_error {
|
|
||||||
Err(_) => {
|
|
||||||
self.send_notice("failed to process message.").await;
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
info!("Message processed successfully");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Everything else below is basically websocket error handling.
|
|
||||||
Ok(Message::Binary(_)) => {
|
|
||||||
info!("Ignoring Binary message");
|
|
||||||
self.send_notice("binary messages not supported.").await;
|
|
||||||
}
|
|
||||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => debug!("Ping/Pong"),
|
|
||||||
Ok(Message::Close(_)) => {
|
|
||||||
info!("Got request to close connection");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(tungstenite::error::Error::Capacity(
|
|
||||||
tungstenite::error::CapacityError::MessageTooLong { size, max_size },
|
|
||||||
)) => {
|
|
||||||
info!(
|
|
||||||
"Message size too large, disconnecting this client. ({} > {})",
|
|
||||||
size, max_size
|
|
||||||
);
|
|
||||||
self.send_notice("binary messages not supported.").await;
|
|
||||||
self.stream
|
|
||||||
.close(Some(CloseFrame {
|
|
||||||
code: CloseCode::Size,
|
|
||||||
reason: "Exceeded max message size".into(),
|
|
||||||
}))
|
|
||||||
.await
|
|
||||||
.expect("failed to send close frame");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(AlreadyClosed) => {
|
|
||||||
warn!("this connection was already closed, and we tried to operate on it");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(ConnectionClosed) | Err(Io(_)) => {
|
|
||||||
debug!("Closing this connection normally");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(Tls(_)) | Err(Protocol(_)) | Err(Utf8) | Err(Url(_)) | Err(HttpFormat(_))
|
|
||||||
| Err(Http(_)) => {
|
|
||||||
info!("websocket/tls/enc protocol error, dropping connection");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Some new kind of error, bailing: {:?}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sending notice to client. never fails.
|
|
||||||
pub async fn send_notice(&mut self, msg: &str) {
|
|
||||||
// TODO: real escaping
|
|
||||||
let s = msg.replace("\"", "");
|
|
||||||
self.stream
|
|
||||||
.send(Message::Text(format!("[\"NOTICE\",\"{}\"]", s)))
|
|
||||||
.await
|
|
||||||
.err();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error results will be transformed into client NOTICEs
|
|
||||||
pub fn process_message(&mut self, cmd: String) -> Result<()> {
|
|
||||||
info!(
|
|
||||||
"Processing message in proto for client: {:?}",
|
|
||||||
self.client_id
|
|
||||||
);
|
|
||||||
let message = parse_cmd(cmd)?;
|
|
||||||
info!("Parsed message: {:?}", message);
|
|
||||||
match message {
|
|
||||||
NostrRequest::EvReq(_) => {}
|
|
||||||
NostrRequest::SubReq(sub) => self.subscribe(sub),
|
|
||||||
NostrRequest::CloseReq(close) => self.unsubscribe(close),
|
|
||||||
};
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&mut self, s: Subscription) {
|
|
||||||
// TODO: add NOTICE responses for error conditions. At the
|
|
||||||
// moment, we are silently dropping subscription requests that
|
|
||||||
// aren't perfect.
|
|
||||||
|
|
||||||
// check if the subscription key is reasonable.
|
|
||||||
let k = s.get_id();
|
|
||||||
let sub_id_len = k.len();
|
|
||||||
if sub_id_len > MAX_SUBSCRIPTION_ID_LEN {
|
|
||||||
info!("Dropping subscription with huge ({}) length", sub_id_len);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// check if an existing subscription exists, and replace if so
|
|
||||||
if self.subscriptions.contains_key(&k) {
|
|
||||||
self.subscriptions.remove(&k);
|
|
||||||
self.subscriptions.insert(k, s);
|
|
||||||
info!("Replaced existing subscription");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if there is room for another subscription.
|
|
||||||
if self.subscriptions.len() >= self.max_subs {
|
|
||||||
info!("Client has reached the maximum number of unique subscriptions");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// add subscription
|
|
||||||
self.subscriptions.insert(k, s);
|
|
||||||
info!(
|
|
||||||
"Registered new subscription, currently have {} active subs",
|
|
||||||
self.subscriptions.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unsubscribe(&mut self, c: Close) {
|
|
||||||
// TODO: return notice if subscription did not exist.
|
|
||||||
self.subscriptions.remove(&c.get_id());
|
|
||||||
info!(
|
|
||||||
"Removed subscription, currently have {} active subs",
|
|
||||||
self.subscriptions.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A raw message with the expected type
|
|
||||||
#[derive(PartialEq, Debug)]
|
|
||||||
pub enum NostrRawMessage {
|
|
||||||
EvRaw(String),
|
|
||||||
SubRaw(String),
|
|
||||||
CloseRaw(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fully parsed request
|
|
||||||
#[derive(PartialEq, Debug)]
|
|
||||||
pub enum NostrRequest {
|
|
||||||
EvReq(Event),
|
|
||||||
SubReq(Subscription),
|
|
||||||
CloseReq(Close),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap the message in the expected request type
|
|
||||||
fn msg_type_wrapper(msg: String) -> Result<NostrRawMessage> {
|
|
||||||
// check prefix.
|
|
||||||
if msg.starts_with(r#"["EVENT","#) {
|
|
||||||
Ok(NostrRawMessage::EvRaw(msg))
|
|
||||||
} else if msg.starts_with(r#"["REQ","#) {
|
|
||||||
Ok(NostrRawMessage::SubRaw(msg))
|
|
||||||
} else if msg.starts_with(r#"["CLOSE","#) {
|
|
||||||
Ok(NostrRawMessage::CloseRaw(msg))
|
|
||||||
} else {
|
|
||||||
Err(Error::CommandNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_cmd(msg: String) -> Result<NostrRequest> {
|
|
||||||
// turn this raw string into a parsed request
|
|
||||||
let typ = msg_type_wrapper(msg)?;
|
|
||||||
match typ {
|
|
||||||
NostrRawMessage::EvRaw(_) => Err(Error::EventParseFailed),
|
|
||||||
NostrRawMessage::SubRaw(m) => Ok(NostrRequest::SubReq(Subscription::parse(&m)?)),
|
|
||||||
NostrRawMessage::CloseRaw(m) => Ok(NostrRequest::CloseReq(Close::parse(&m)?)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the request into a fully deserialized type
|
|
||||||
|
|
||||||
// The protocol-handling process looks something like:
|
|
||||||
// Receive a message (bytes).
|
|
||||||
// Determine the type. We could do this with an untagged deserialization in serde. Or we can peek at the prefix.
|
|
||||||
// Wrap the message string in the client request type (either Event, Req, Close)
|
|
||||||
// For Req/Close, we can fully parse these.
|
|
||||||
|
|
||||||
// For Event, we want to be more cautious.
|
|
||||||
// Before we admit an event, we should reject any duplicates.
|
|
||||||
// duplicates in the datastore will have already been sent out to interested subscribers.
|
|
||||||
// No point in verifying an event that we already have.
|
|
||||||
|
|
||||||
// Event pipeline looks like:
|
|
||||||
// Get message. (./)
|
|
||||||
// Verify it is an event. (./)
|
|
||||||
// Parse into string / number components from JSON.
|
|
||||||
// Perform validation, re-serialize (or can we re-use the original?)
|
|
||||||
// Publish to subscribers.
|
|
||||||
// Push to datastore.
|
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
//! Database schema and migrations
|
||||||
|
use crate::db::PooledConnection;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::utils::is_hex;
|
||||||
|
use log::*;
|
||||||
|
use rusqlite::limits::Limit;
|
||||||
|
use rusqlite::params;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
// TODO: drop the pubkey_ref and event_ref tables
|
||||||
|
|
||||||
|
/// Startup DB Pragmas
|
||||||
|
pub 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
|
||||||
|
PRAGMA encoding = "UTF-8";
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA main.synchronous=NORMAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA application_id = 1654008667;
|
||||||
|
PRAGMA user_version = 5;
|
||||||
|
|
||||||
|
-- Event Table
|
||||||
|
CREATE TABLE IF NOT EXISTS event (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
event_hash BLOB NOT NULL, -- 4-byte hash
|
||||||
|
first_seen INTEGER NOT NULL, -- when the event was first seen (not authored!) (seconds since 1970)
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Event Indexes
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS event_hash_index ON event(event_hash);
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- NIP-05 User Validation
|
||||||
|
CREATE TABLE IF NOT EXISTS user_verification (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
metadata_event INTEGER NOT NULL, -- the metadata event used for this validation.
|
||||||
|
name TEXT NOT NULL, -- the nip05 field value (user@domain).
|
||||||
|
verified_at INTEGER, -- timestamp this author/nip05 was most recently verified.
|
||||||
|
failed_at INTEGER, -- timestamp a verification attempt failed (host down).
|
||||||
|
failure_count INTEGER DEFAULT 0, -- number of consecutive failures.
|
||||||
|
FOREIGN KEY(metadata_event) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_verification_name_index ON user_verification(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_verification_event_index ON user_verification(metadata_event);
|
||||||
|
"##;
|
||||||
|
|
||||||
|
/// Determine the current application database schema version.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upgrade DB to latest version, and execute pragma settings
|
||||||
|
pub fn upgrade_db(conn: &mut PooledConnection) -> 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 v4, and ready");
|
||||||
|
}
|
||||||
|
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
|
||||||
|
info!("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
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
if curr_version == 3 {
|
||||||
|
info!("database schema needs update from 3->4");
|
||||||
|
let upgrade_sql = r##"
|
||||||
|
-- incoming metadata events with nip05
|
||||||
|
CREATE TABLE IF NOT EXISTS user_verification (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
metadata_event INTEGER NOT NULL, -- the metadata event used for this validation.
|
||||||
|
name TEXT NOT NULL, -- the nip05 field value (user@domain).
|
||||||
|
verified_at INTEGER, -- timestamp this author/nip05 was most recently verified.
|
||||||
|
failed_at INTEGER, -- timestamp a verification attempt failed (host down).
|
||||||
|
failure_count INTEGER DEFAULT 0, -- number of consecutive failures.
|
||||||
|
FOREIGN KEY(metadata_event) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_verification_name_index ON user_verification(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_verification_event_index ON user_verification(metadata_event);
|
||||||
|
PRAGMA user_version = 4;
|
||||||
|
"##;
|
||||||
|
match conn.execute_batch(upgrade_sql) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("database schema upgraded v3 -> v4");
|
||||||
|
curr_version = 4;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("update failed: {}", err);
|
||||||
|
panic!("database could not be upgraded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if curr_version == 4 {
|
||||||
|
info!("database schema needs update from 4->5");
|
||||||
|
let upgrade_sql = r##"
|
||||||
|
DROP TABLE IF EXISTS event_ref;
|
||||||
|
DROP TABLE IF EXISTS pubkey_ref;
|
||||||
|
PRAGMA user_version=5;
|
||||||
|
"##;
|
||||||
|
match conn.execute_batch(upgrade_sql) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("database schema upgraded v4 -> v5");
|
||||||
|
// uncomment if we have a newer version
|
||||||
|
//curr_version = 5;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("update failed: {}", err);
|
||||||
|
panic!("database could not be upgraded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if curr_version == 5 {
|
||||||
|
debug!("Database version was already current");
|
||||||
|
} else if curr_version > 5 {
|
||||||
|
panic!("Database version is newer than supported by this executable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup PRAGMA
|
||||||
|
conn.execute_batch(STARTUP_SQL)?;
|
||||||
|
debug!("SQLite PRAGMA startup completed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+256
-118
@@ -1,42 +1,104 @@
|
|||||||
use crate::error::{Error, Result};
|
//! Subscription and filter parsing
|
||||||
|
use crate::error::Result;
|
||||||
use crate::event::Event;
|
use crate::event::Event;
|
||||||
|
use serde::de::Unexpected;
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
//use serde_json::json;
|
use serde_json::Value;
|
||||||
//use serde_json::Result;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
// Container for a request filter
|
/// Subscription identifier and set of request filters
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
#[derive(Serialize, PartialEq, Debug, Clone)]
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct ReqCmd {
|
|
||||||
cmds: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Debug, Clone)]
|
|
||||||
pub struct Subscription {
|
pub struct Subscription {
|
||||||
id: String,
|
pub id: String,
|
||||||
filters: Vec<ReqFilter>,
|
pub filters: Vec<ReqFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
|
/// Filter for requests
|
||||||
#[serde(deny_unknown_fields)]
|
///
|
||||||
|
/// 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, PartialEq, Debug, Clone)]
|
||||||
pub struct ReqFilter {
|
pub struct ReqFilter {
|
||||||
id: Option<String>,
|
/// Event hashes
|
||||||
author: Option<String>,
|
pub ids: Option<Vec<String>>,
|
||||||
kind: Option<u8>,
|
/// Event kinds
|
||||||
#[serde(rename = "e#")]
|
pub kinds: Option<Vec<u64>>,
|
||||||
event: Option<String>,
|
/// Events published after this time
|
||||||
#[serde(rename = "p#")]
|
pub since: Option<u64>,
|
||||||
pubkey: Option<String>,
|
/// Events published before this time
|
||||||
since: Option<u64>,
|
pub until: Option<u64>,
|
||||||
authors: Option<Vec<String>>,
|
/// 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 {
|
impl<'de> Deserialize<'de> for Subscription {
|
||||||
|
/// Custom deserializer for subscriptions, which have a more
|
||||||
|
/// complex structure than the other message types.
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Subscription, D::Error>
|
fn deserialize<D>(deserializer: D) -> Result<Subscription, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
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.
|
// this shoud be a 3-or-more element array.
|
||||||
// verify the first element is a String, REQ
|
// verify the first element is a String, REQ
|
||||||
// get the subscription from the second element.
|
// get the subscription from the second element.
|
||||||
@@ -45,18 +107,18 @@ impl<'de> Deserialize<'de> for Subscription {
|
|||||||
// check for array
|
// check for array
|
||||||
let va = v
|
let va = v
|
||||||
.as_array_mut()
|
.as_array_mut()
|
||||||
.ok_or(serde::de::Error::custom("not array"))?;
|
.ok_or_else(|| serde::de::Error::custom("not array"))?;
|
||||||
|
|
||||||
// check length
|
// check length
|
||||||
if va.len() < 3 {
|
if va.len() < 3 {
|
||||||
return Err(serde::de::Error::custom("not enough fields"));
|
return Err(serde::de::Error::custom("not enough fields"));
|
||||||
}
|
}
|
||||||
let mut i = va.into_iter();
|
let mut i = va.iter_mut();
|
||||||
// get command ("REQ") and ensure it is a string
|
// get command ("REQ") and ensure it is a string
|
||||||
let req_cmd_str: serde_json::Value = i.next().unwrap().take();
|
let req_cmd_str: serde_json::Value = i.next().unwrap().take();
|
||||||
let req = req_cmd_str.as_str().ok_or(serde::de::Error::custom(
|
let req = req_cmd_str
|
||||||
"first element of request was not a string",
|
.as_str()
|
||||||
))?;
|
.ok_or_else(|| serde::de::Error::custom("first element of request was not a string"))?;
|
||||||
if req != "REQ" {
|
if req != "REQ" {
|
||||||
return Err(serde::de::Error::custom("missing REQ command"));
|
return Err(serde::de::Error::custom("missing REQ command"));
|
||||||
}
|
}
|
||||||
@@ -65,12 +127,13 @@ impl<'de> Deserialize<'de> for Subscription {
|
|||||||
let sub_id_str: serde_json::Value = i.next().unwrap().take();
|
let sub_id_str: serde_json::Value = i.next().unwrap().take();
|
||||||
let sub_id = sub_id_str
|
let sub_id = sub_id_str
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or(serde::de::Error::custom("missing subscription id"))?;
|
.ok_or_else(|| serde::de::Error::custom("missing subscription id"))?;
|
||||||
|
|
||||||
let mut filters = vec![];
|
let mut filters = vec![];
|
||||||
for fv in i {
|
for fv in i {
|
||||||
let f: ReqFilter = serde_json::from_value(fv.take())
|
let f: ReqFilter = serde_json::from_value(fv.take())
|
||||||
.map_err(|_| serde::de::Error::custom("could not parse filter"))?;
|
.map_err(|_| serde::de::Error::custom("could not parse filter"))?;
|
||||||
|
// create indexes
|
||||||
filters.push(f);
|
filters.push(f);
|
||||||
}
|
}
|
||||||
Ok(Subscription {
|
Ok(Subscription {
|
||||||
@@ -81,70 +144,80 @@ impl<'de> Deserialize<'de> for Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Subscription {
|
impl Subscription {
|
||||||
pub fn parse(json: &str) -> Result<Subscription> {
|
/// Get a copy of the subscription identifier.
|
||||||
serde_json::from_str(json).map_err(|e| Error::JsonParseFailed(e))
|
|
||||||
}
|
|
||||||
pub fn get_id(&self) -> String {
|
pub fn get_id(&self) -> String {
|
||||||
self.id.clone()
|
self.id.clone()
|
||||||
}
|
}
|
||||||
pub fn get_filter_count(&self) -> usize {
|
/// Determine if this subscription matches a given [`Event`]. Any
|
||||||
self.filters.len()
|
/// individual filter match is sufficient.
|
||||||
}
|
|
||||||
pub fn interested_in_event(&self, event: &Event) -> bool {
|
pub fn interested_in_event(&self, event: &Event) -> bool {
|
||||||
// loop through every filter, and return true if any match this event.
|
|
||||||
for f in self.filters.iter() {
|
for f in self.filters.iter() {
|
||||||
if f.interested_in_event(event) {
|
if f.interested_in_event(event) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReqFilter {
|
fn prefix_match(prefixes: &[String], target: &str) -> bool {
|
||||||
fn authors_match(&self, author: &str) -> bool {
|
for prefix in prefixes {
|
||||||
if self
|
if target.starts_with(prefix) {
|
||||||
.authors
|
|
||||||
.as_ref()
|
|
||||||
.map(|vs| vs.contains(&author.to_owned()))
|
|
||||||
.unwrap_or(true)
|
|
||||||
{
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// none matched
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub fn interested_in_event(&self, event: &Event) -> bool {
|
impl ReqFilter {
|
||||||
// determine if all populated fields in this filter match the provided event.
|
fn ids_match(&self, event: &Event) -> bool {
|
||||||
// a filter matches an event if all the populated fields match.
|
self.ids
|
||||||
// Iterate through each filter field, return false if the field exists and doesn't match the event.
|
|
||||||
// order based on cost; id, time, kind, author, event
|
|
||||||
if !self.id.as_ref().map(|v| v == &event.id).unwrap_or(true) {
|
|
||||||
false
|
|
||||||
} else if !self.since.map(|t| event.created_at > t).unwrap_or(true) {
|
|
||||||
false
|
|
||||||
} else if !self.kind.map(|v| v == event.kind).unwrap_or(true) {
|
|
||||||
false
|
|
||||||
} else if !self
|
|
||||||
.author
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|v| v == &event.pubkey)
|
.map(|vs| prefix_match(vs, &event.id))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
{
|
}
|
||||||
false
|
|
||||||
} else if !self.authors_match(&event.pubkey) {
|
fn authors_match(&self, event: &Event) -> bool {
|
||||||
false
|
self.authors
|
||||||
} else if !self
|
|
||||||
.event
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|e| event.event_tag_match(e))
|
.map(|vs| prefix_match(vs, &event.pubkey))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
{
|
}
|
||||||
false
|
|
||||||
} else {
|
fn tag_match(&self, event: &Event) -> bool {
|
||||||
true
|
// 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.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.ids_match(event)
|
||||||
|
&& self.since.map(|t| event.created_at > t).unwrap_or(true)
|
||||||
|
&& self.until.map(|t| event.created_at < t).unwrap_or(true)
|
||||||
|
&& self.kind_match(event.kind)
|
||||||
|
&& self.authors_match(event)
|
||||||
|
&& self.tag_match(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,17 +231,7 @@ mod tests {
|
|||||||
let s: Subscription = serde_json::from_str(raw_json)?;
|
let s: Subscription = serde_json::from_str(raw_json)?;
|
||||||
assert_eq!(s.id, "some-id");
|
assert_eq!(s.id, "some-id");
|
||||||
assert_eq!(s.filters.len(), 1);
|
assert_eq!(s.filters.len(), 1);
|
||||||
assert_eq!(s.filters.get(0).unwrap().author, None);
|
assert_eq!(s.filters.get(0).unwrap().authors, None);
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multi_empty_request_parse() -> Result<()> {
|
|
||||||
let raw_json = r#"["REQ","some-id",{}]"#;
|
|
||||||
let s: Subscription = serde_json::from_str(raw_json)?;
|
|
||||||
assert_eq!(s.id, "some-id");
|
|
||||||
assert_eq!(s.filters.len(), 1);
|
|
||||||
assert_eq!(s.filters.get(0).unwrap().author, None);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,27 +248,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_filter() {
|
fn legacy_filter() {
|
||||||
// unrecognized field in filter
|
// legacy field in filter
|
||||||
let raw_json = "[\"REQ\",\"some-id\",{\"foo\": 3}]";
|
let raw_json = "[\"REQ\",\"some-id\",{\"kind\": 3}]";
|
||||||
assert!(serde_json::from_str::<Subscription>(raw_json).is_err());
|
assert!(serde_json::from_str::<Subscription>(raw_json).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn author_filter() -> Result<()> {
|
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)?;
|
let s: Subscription = serde_json::from_str(raw_json)?;
|
||||||
assert_eq!(s.id, "some-id");
|
assert_eq!(s.id, "some-id");
|
||||||
assert_eq!(s.filters.len(), 1);
|
assert_eq!(s.filters.len(), 1);
|
||||||
let first_filter = s.filters.get(0).unwrap();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interest_id_nomatch() -> Result<()> {
|
fn interest_id_nomatch() -> Result<()> {
|
||||||
// subscription with a filter for ID
|
// 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 {
|
let e = Event {
|
||||||
id: "abcde".to_owned(),
|
id: "abcde".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
@@ -214,15 +316,17 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".to_owned(),
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
};
|
};
|
||||||
assert_eq!(s.interested_in_event(&e), false);
|
assert!(!s.interested_in_event(&e));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interest_time_and_id() -> Result<()> {
|
fn interest_until() -> Result<()> {
|
||||||
// subscription with a filter for ID and time
|
// 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"], "until": 1000}]"#)?;
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abc".to_owned(),
|
id: "abc".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
@@ -231,8 +335,53 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".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_range() -> Result<()> {
|
||||||
|
// subscription with a filter for ID and time
|
||||||
|
let s_in: Subscription =
|
||||||
|
serde_json::from_str(r#"["REQ","xyz",{"ids": ["abc"], "since": 100, "until": 200}]"#)?;
|
||||||
|
let s_before: Subscription =
|
||||||
|
serde_json::from_str(r#"["REQ","xyz",{"ids": ["abc"], "since": 100, "until": 140}]"#)?;
|
||||||
|
let s_after: Subscription =
|
||||||
|
serde_json::from_str(r#"["REQ","xyz",{"ids": ["abc"], "since": 160, "until": 200}]"#)?;
|
||||||
|
let e = Event {
|
||||||
|
id: "abc".to_owned(),
|
||||||
|
pubkey: "".to_owned(),
|
||||||
|
created_at: 150,
|
||||||
|
kind: 0,
|
||||||
|
tags: Vec::new(),
|
||||||
|
content: "".to_owned(),
|
||||||
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
|
};
|
||||||
|
assert!(s_in.interested_in_event(&e));
|
||||||
|
assert!(!s_before.interested_in_event(&e));
|
||||||
|
assert!(!s_after.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",{"ids": ["abc"], "since": 1000}]"#)?;
|
||||||
|
let e = Event {
|
||||||
|
id: "abc".to_owned(),
|
||||||
|
pubkey: "".to_owned(),
|
||||||
|
created_at: 50,
|
||||||
|
kind: 0,
|
||||||
|
tags: Vec::new(),
|
||||||
|
content: "".to_owned(),
|
||||||
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
|
};
|
||||||
|
assert!(!s.interested_in_event(&e));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +397,9 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".to_owned(),
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
};
|
};
|
||||||
assert_eq!(s.interested_in_event(&e), true);
|
assert!(s.interested_in_event(&e));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,25 +415,9 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".to_owned(),
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
};
|
};
|
||||||
assert_eq!(s.interested_in_event(&e), true);
|
assert!(s.interested_in_event(&e));
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn author_single() -> Result<()> {
|
|
||||||
// subscription with a filter for ID
|
|
||||||
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"author":"abc"}]"#)?;
|
|
||||||
let e = Event {
|
|
||||||
id: "123".to_owned(),
|
|
||||||
pubkey: "abc".to_owned(),
|
|
||||||
created_at: 0,
|
|
||||||
kind: 0,
|
|
||||||
tags: Vec::new(),
|
|
||||||
content: "".to_owned(),
|
|
||||||
sig: "".to_owned(),
|
|
||||||
};
|
|
||||||
assert_eq!(s.interested_in_event(&e), true);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,11 +433,13 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".to_owned(),
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
};
|
};
|
||||||
assert_eq!(s.interested_in_event(&e), true);
|
assert!(s.interested_in_event(&e));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
|
||||||
fn authors_multi_pubkey() -> Result<()> {
|
fn authors_multi_pubkey() -> Result<()> {
|
||||||
// check for any of a set of authors, against the pubkey
|
// check for any of a set of authors, against the pubkey
|
||||||
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"authors":["abc", "bcd"]}]"#)?;
|
let s: Subscription = serde_json::from_str(r#"["REQ","xyz",{"authors":["abc", "bcd"]}]"#)?;
|
||||||
@@ -315,8 +451,9 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".to_owned(),
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
};
|
};
|
||||||
assert_eq!(s.interested_in_event(&e), true);
|
assert!(s.interested_in_event(&e));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,8 +469,9 @@ mod tests {
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
content: "".to_owned(),
|
content: "".to_owned(),
|
||||||
sig: "".to_owned(),
|
sig: "".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
};
|
};
|
||||||
assert_eq!(s.interested_in_event(&e), false);
|
assert!(!s.interested_in_event(&e));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Common utility functions
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// Seconds since 1970.
|
||||||
|
pub fn unix_time() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.map(|x| x.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a string contains only hex characters.
|
||||||
|
pub fn is_hex(s: &str) -> bool {
|
||||||
|
s.chars().all(|x| char::is_ascii_hexdigit(&x))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user