Compare commits

...

9 Commits
0.2.3 ... 0.3.3

Author SHA1 Message Date
Greg Heartsfield
d78bbfc290 build: bump version to 0.3.3 2022-01-03 22:07:15 -05:00
Greg Heartsfield
2924da88bc feat: incorporated improvements from NIP-11 discussion
Change descr to description.  Add `id` for websocket URL.  Use
integers for supported NIPs instead of strings.  Top-level is object,
instead of the array before.
2022-01-03 22:03:30 -05:00
Greg Heartsfield
3024e9fba4 build: bump version to 0.3.2 2022-01-03 18:43:17 -05:00
Greg Heartsfield
d3da4eb009 feat: implementation of proposed NIP-11 (server metadata) 2022-01-03 18:42:24 -05:00
Greg Heartsfield
19637d612e build: bump version to 0.3.1 2022-01-01 19:26:15 -06:00
Greg Heartsfield
afc9a0096a improvement: logging failed queries and timing 2022-01-01 19:25:09 -06:00
Greg Heartsfield
3d56262386 build: bump version to 0.3.0 2022-01-01 18:40:57 -06:00
Greg Heartsfield
6673fcfd11 feat: implement multi-valued filter searching
NIP-01 now uses arrays instead of scalars.

Fixes https://todo.sr.ht/~gheartsfield/nostr-rs-relay/17
2022-01-01 18:38:52 -06:00
Greg Heartsfield
b5da3fa2b0 docs: link to docker hub 2022-01-01 12:27:09 -06:00
11 changed files with 222 additions and 46 deletions

3
Cargo.lock generated
View File

@@ -649,7 +649,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nostr-rs-relay" name = "nostr-rs-relay"
version = "0.2.3" version = "0.3.3"
dependencies = [ dependencies = [
"bitcoin_hashes 0.9.7", "bitcoin_hashes 0.9.7",
"config", "config",
@@ -1097,6 +1097,7 @@ version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527" checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527"
dependencies = [ dependencies = [
"indexmap",
"itoa", "itoa",
"ryu", "ryu",
"serde 1.0.131", "serde 1.0.131",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "nostr-rs-relay" name = "nostr-rs-relay"
version = "0.2.3" version = "0.3.3"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -17,7 +17,7 @@ config = { version = "0.11", features = ["toml"] }
bitcoin_hashes = { version = "^0.9", features = ["serde"] } bitcoin_hashes = { version = "^0.9", features = ["serde"] }
secp256k1 = {git = "https://github.com/rust-bitcoin/rust-secp256k1.git", rev = "50034ccb18fdd84904ab3aa6c84a12fcced33209", features = ["rand", "rand-std", "serde", "bitcoin_hashes"] } secp256k1 = {git = "https://github.com/rust-bitcoin/rust-secp256k1.git", rev = "50034ccb18fdd84904ab3aa6c84a12fcced33209", features = ["rand", "rand-std", "serde", "bitcoin_hashes"] }
serde = { version = "^1.0", features = ["derive"] } serde = { version = "^1.0", features = ["derive"] }
serde_json = "^1.0" serde_json = {version = "^1.0", features = ["preserve_order"]}
hex = "^0.4" hex = "^0.4"
rusqlite = "^0.26" rusqlite = "^0.26"
lazy_static = "^1.4" lazy_static = "^1.4"

View File

@@ -39,6 +39,9 @@ Text Note [81cf...2652] from 296a...9b92 5 seconds ago
hello world hello world
``` ```
A pre-built container is also available on DockerHub:
https://hub.docker.com/repository/docker/scsibug/nostr-rs-relay
## Configuration ## Configuration
The sample `[config.toml](config.toml)` file demonstrates the The sample `[config.toml](config.toml)` file demonstrates the

View File

@@ -1,4 +1,21 @@
# Nostr-rs-relay configuration # 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 email
#email = "contact@example.com"
[database] [database]
# Directory for SQLite files. Defaults to the current directory. Can # Directory for SQLite files. Defaults to the current directory. Can
# also be specified (and overriden) with the "--db dirname" command # also be specified (and overriden) with the "--db dirname" command

View File

@@ -8,6 +8,16 @@ lazy_static! {
pub static ref SETTINGS: RwLock<Settings> = RwLock::new(Settings::default()); pub static ref SETTINGS: RwLock<Settings> = RwLock::new(Settings::default());
} }
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct Info {
pub relay_url: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub pubkey: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[allow(unused)] #[allow(unused)]
pub struct Database { pub struct Database {
@@ -52,6 +62,7 @@ pub struct Limits {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[allow(unused)] #[allow(unused)]
pub struct Settings { pub struct Settings {
pub info: Info,
pub database: Database, pub database: Database,
pub network: Network, pub network: Network,
pub limits: Limits, pub limits: Limits,
@@ -89,6 +100,13 @@ impl Settings {
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Settings { Settings {
info: Info {
relay_url: None,
name: Some("Unnamed nostr-rs-relay".to_owned()),
description: None,
pubkey: None,
email: None,
},
database: Database { database: Database {
data_directory: ".".to_owned(), data_directory: ".".to_owned(),
}, },

View File

@@ -13,6 +13,7 @@ use rusqlite::OpenFlags;
use crate::config::SETTINGS; use crate::config::SETTINGS;
use std::path::Path; use std::path::Path;
use std::thread; use std::thread;
use std::time::Instant;
use tokio::task; use tokio::task;
/// Database file /// Database file
@@ -157,12 +158,17 @@ pub async fn db_writer(
} }
let mut event_write = false; let mut event_write = false;
let event = next_event.unwrap(); let event = next_event.unwrap();
let start = Instant::now();
match write_event(&mut conn, &event) { match write_event(&mut conn, &event) {
Ok(updated) => { Ok(updated) => {
if updated == 0 { if updated == 0 {
debug!("ignoring duplicate event"); debug!("ignoring duplicate event");
} else { } else {
info!("persisted event: {}", event.get_event_id_prefix()); info!(
"persisted event: {} in {:?}",
event.get_event_id_prefix(),
start.elapsed()
);
event_write = true; event_write = true;
// send this out to all clients // send this out to all clients
bcast_tx.send(event.clone()).ok(); bcast_tx.send(event.clone()).ok();
@@ -302,35 +308,52 @@ fn query_from_sub(sub: &Subscription) -> String {
filter_components.push(authors_clause); filter_components.push(authors_clause);
} }
// Query for Kind // Query for Kind
if f.kind.is_some() { if let Some(ks) = &f.kinds {
// kind is number, no escaping needed // kind is number, no escaping needed
let kind_clause = format!("kind = {}", f.kind.unwrap()); let str_kinds: Vec<String> = ks.iter().map(|x| x.to_string()).collect();
let kind_clause = format!("kind IN ({})", str_kinds.join(", "));
filter_components.push(kind_clause); filter_components.push(kind_clause);
} }
// Query for event // Query for event
if f.id.is_some() { if f.ids.is_some() {
let id_str = f.id.as_ref().unwrap(); let ids_escaped: Vec<String> = f
if is_hex(id_str) { .ids
let id_clause = format!("event_hash = x'{}'", id_str); .as_ref()
.unwrap()
.iter()
.filter(|&x| is_hex(x))
.map(|x| format!("x'{}'", x))
.collect();
let id_clause = format!("event_hash IN ({})", ids_escaped.join(", "));
filter_components.push(id_clause); filter_components.push(id_clause);
} }
}
// Query for referenced event // Query for referenced event
if f.event.is_some() { if f.events.is_some() {
let ev_str = f.event.as_ref().unwrap(); let events_escaped: Vec<String> = f
if is_hex(ev_str) { .events
let ev_clause = format!("referenced_event = x'{}'", ev_str); .as_ref()
filter_components.push(ev_clause); .unwrap()
} .iter()
} .filter(|&x| is_hex(x))
// Query for referenced pet name pubkey .map(|x| format!("x'{}'", x))
if f.pubkey.is_some() { .collect();
let pet_str = f.pubkey.as_ref().unwrap(); let events_clause = format!("referenced_event IN ({})", events_escaped.join(", "));
if is_hex(pet_str) { filter_components.push(events_clause);
let pet_clause = format!("referenced_pubkey = x'{}'", pet_str);
filter_components.push(pet_clause);
} }
// Query for referenced pubkey
if f.pubkeys.is_some() {
let pubkeys_escaped: Vec<String> = f
.pubkeys
.as_ref()
.unwrap()
.iter()
.filter(|&x| is_hex(x))
.map(|x| format!("x'{}'", x))
.collect();
let pubkeys_clause = format!("referenced_pubkey IN ({})", pubkeys_escaped.join(", "));
filter_components.push(pubkeys_clause);
} }
// Query for timestamp // Query for timestamp
if f.since.is_some() { if f.since.is_some() {
let created_clause = format!("created_at > {}", f.since.unwrap()); let created_clause = format!("created_at > {}", f.since.unwrap());
@@ -385,6 +408,8 @@ pub async fn db_query(
Connection::open_with_flags(&full_path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); Connection::open_with_flags(&full_path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
debug!("opened database for reading"); debug!("opened database for reading");
debug!("going to query for: {:?}", sub); debug!("going to query for: {:?}", sub);
let mut row_count: usize = 0;
let start = Instant::now();
// generate SQL query // generate SQL query
let q = query_from_sub(&sub); let q = query_from_sub(&sub);
// execute the query // execute the query
@@ -396,6 +421,7 @@ pub async fn db_query(
debug!("query aborted"); debug!("query aborted");
return; return;
} }
row_count += 1;
// TODO: check before unwrapping // TODO: check before unwrapping
let event_json = row.get(0).unwrap(); let event_json = row.get(0).unwrap();
query_tx query_tx
@@ -405,6 +431,10 @@ pub async fn db_query(
}) })
.ok(); .ok();
} }
debug!("query completed"); debug!(
"query completed ({} rows) in {:?}",
row_count,
start.elapsed()
);
}); });
} }

60
src/info.rs Normal file
View File

@@ -0,0 +1,60 @@
use crate::config;
/// Relay Info
use serde::{Deserialize, Serialize};
const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
#[derive(Debug, Serialize, Deserialize)]
#[allow(unused)]
pub struct RelayInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: 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>,
}
impl Default for RelayInfo {
fn default() -> Self {
RelayInfo {
id: None,
name: None,
description: None,
pubkey: None,
email: None,
supported_nips: Some(vec![1]),
software: Some("https://git.sr.ht/~gheartsfield/nostr-rs-relay".to_owned()),
version: CARGO_PKG_VERSION.map(|x| x.to_owned()),
}
}
}
/// Convert an Info struct into Relay Info json string
pub fn relay_info_json(info: &config::Info) -> String {
// get a default RelayInfo
let mut r = RelayInfo::default();
// update fields from Info, if present
r.id = info.relay_url.clone();
r.name = info.name.clone();
r.description = info.description.clone();
r.pubkey = info.pubkey.clone();
r.email = info.email.clone();
r.to_json()
}
impl RelayInfo {
pub fn to_json(self) -> String {
serde_json::to_string_pretty(&self).unwrap()
}
}

View File

@@ -4,5 +4,6 @@ pub mod conn;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod event; pub mod event;
pub mod info;
pub mod protostream; pub mod protostream;
pub mod subscription; pub mod subscription;

View File

@@ -1,6 +1,7 @@
//! Server process //! Server process
use futures::SinkExt; use futures::SinkExt;
use futures::StreamExt; use futures::StreamExt;
use hyper::header::ACCEPT;
use hyper::service::{make_service_fn, service_fn}; use hyper::service::{make_service_fn, service_fn};
use hyper::upgrade::Upgraded; use hyper::upgrade::Upgraded;
use hyper::{ use hyper::{
@@ -13,6 +14,7 @@ use nostr_rs_relay::conn;
use nostr_rs_relay::db; use nostr_rs_relay::db;
use nostr_rs_relay::error::{Error, Result}; use nostr_rs_relay::error::{Error, Result};
use nostr_rs_relay::event::Event; use nostr_rs_relay::event::Event;
use nostr_rs_relay::info::relay_info_json;
use nostr_rs_relay::protostream; use nostr_rs_relay::protostream;
use nostr_rs_relay::protostream::NostrMessage::*; use nostr_rs_relay::protostream::NostrMessage::*;
use nostr_rs_relay::protostream::NostrResponse::*; use nostr_rs_relay::protostream::NostrResponse::*;
@@ -47,7 +49,7 @@ async fn handle_web_request(
request.uri().path(), request.uri().path(),
request.headers().contains_key(header::UPGRADE), request.headers().contains_key(header::UPGRADE),
) { ) {
//if the request is ws_echo and the request headers contains an Upgrade key // Request for / as websocket
("/", true) => { ("/", true) => {
debug!("websocket with upgrade request"); debug!("websocket with upgrade request");
//assume request is a handshake, so create the handshake response //assume request is a handshake, so create the handshake response
@@ -96,10 +98,29 @@ async fn handle_web_request(
}; };
Ok::<_, Infallible>(response) Ok::<_, Infallible>(response)
} }
// Request for Relay info
("/", false) => { ("/", false) => {
// handle request at root with no upgrade header // 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 b = Body::from(relay_info_json(&config.info));
return Ok(Response::builder()
.status(200)
.header("Content-Type", "application/nostr+json")
.body(b)
.unwrap());
}
}
}
Ok(Response::new(Body::from( Ok(Response::new(Body::from(
"This is a Nostr relay.\n".to_string(), "Please use a Nostr client to connect.",
))) )))
} }
(_, _) => { (_, _) => {

View File

@@ -71,6 +71,7 @@ impl Stream for NostrStream {
} }
Err(e) => { Err(e) => {
debug!("proto parse error: {:?}", e); debug!("proto parse error: {:?}", e);
debug!("parse error on message: {}", msg.trim());
Err(Error::ProtoParseError) Err(Error::ProtoParseError)
} }
} }

View File

@@ -2,6 +2,7 @@
use crate::error::Result; use crate::error::Result;
use crate::event::Event; use crate::event::Event;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashSet;
/// Subscription identifier and set of request filters /// Subscription identifier and set of request filters
#[derive(Serialize, PartialEq, Debug, Clone)] #[derive(Serialize, PartialEq, Debug, Clone)]
@@ -17,16 +18,16 @@ pub struct Subscription {
/// absent ([`None`]) if it should be ignored. /// absent ([`None`]) if it should be ignored.
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct ReqFilter { pub struct ReqFilter {
/// Event hash /// Event hashes
pub id: Option<String>, pub ids: Option<Vec<String>>,
/// Event kind /// Event kinds
pub kind: Option<u64>, pub kinds: Option<Vec<u64>>,
/// Referenced event hash /// Referenced event hash
#[serde(rename = "#e")] #[serde(rename = "#e")]
pub event: Option<String>, pub events: Option<Vec<String>>,
/// Referenced public key for a petname /// Referenced public key for a petname
#[serde(rename = "#p")] #[serde(rename = "#p")]
pub pubkey: Option<String>, pub pubkeys: Option<Vec<String>>,
/// Events published after this time /// Events published after this time
pub since: Option<u64>, pub since: Option<u64>,
/// Events published before this time /// Events published before this time
@@ -105,8 +106,13 @@ impl Subscription {
impl ReqFilter { impl ReqFilter {
/// Check for a match within the authors list. /// Check for a match within the authors list.
// TODO: Ambiguity; what if the array is empty? Should we fn ids_match(&self, event: &Event) -> bool {
// consider that the same as null? self.ids
.as_ref()
.map(|vs| vs.contains(&event.id.to_owned()))
.unwrap_or(true)
}
fn authors_match(&self, event: &Event) -> bool { fn authors_match(&self, event: &Event) -> bool {
self.authors self.authors
.as_ref() .as_ref()
@@ -115,29 +121,47 @@ impl ReqFilter {
} }
/// Check if this filter either matches, or does not care about the event tags. /// Check if this filter either matches, or does not care about the event tags.
fn event_match(&self, event: &Event) -> bool { fn event_match(&self, event: &Event) -> bool {
self.event // This needs to be analyzed for performance; building these
.as_ref() // hash sets for each active subscription isn't great.
.map(|t| event.event_tag_match(t)) if let Some(es) = &self.events {
.unwrap_or(true) let event_refs =
HashSet::<_>::from_iter(event.get_event_tags().iter().map(|x| x.to_owned()));
let filter_refs = HashSet::<_>::from_iter(es.iter().map(|x| &x[..]));
let cardinality = event_refs.intersection(&filter_refs).count();
cardinality > 0
} else {
true
}
} }
/// Check if this filter either matches, or does not care about /// Check if this filter either matches, or does not care about
/// the pubkey/petname tags. /// the pubkey/petname tags.
fn pubkey_match(&self, event: &Event) -> bool { fn pubkey_match(&self, event: &Event) -> bool {
self.pubkey // This needs to be analyzed for performance; building these
.as_ref() // hash sets for each active subscription isn't great.
.map(|t| event.pubkey_tag_match(t)) if let Some(ps) = &self.pubkeys {
.unwrap_or(true) let pubkey_refs =
HashSet::<_>::from_iter(event.get_pubkey_tags().iter().map(|x| x.to_owned()));
let filter_refs = HashSet::<_>::from_iter(ps.iter().map(|x| &x[..]));
let cardinality = pubkey_refs.intersection(&filter_refs).count();
cardinality > 0
} else {
true
}
} }
/// Check if this filter either matches, or does not care about the kind. /// Check if this filter either matches, or does not care about the kind.
fn kind_match(&self, kind: u64) -> bool { fn kind_match(&self, kind: u64) -> bool {
self.kind.map(|v| v == kind).unwrap_or(true) self.kinds
.as_ref()
.map(|ks| ks.contains(&kind))
.unwrap_or(true)
} }
/// Determine if all populated fields in this filter match the provided event. /// Determine if all populated fields in this filter match the provided event.
pub fn interested_in_event(&self, event: &Event) -> bool { pub fn interested_in_event(&self, event: &Event) -> bool {
self.id.as_ref().map(|v| v == &event.id).unwrap_or(true) // self.id.as_ref().map(|v| v == &event.id).unwrap_or(true)
self.ids_match(event)
&& self.since.map(|t| event.created_at > t).unwrap_or(true) && self.since.map(|t| event.created_at > t).unwrap_or(true)
&& self.kind_match(event.kind) && self.kind_match(event.kind)
&& self.authors_match(event) && self.authors_match(event)