From fab1aa3c103c74f1d653ca2764bcc73dcb84e9f7 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sat, 16 Nov 2024 20:16:08 +1300 Subject: [PATCH 01/21] From for std::io::Error (map back, as some stream work will need this) --- src/error.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/error.rs b/src/error.rs index 95d71da..ce8a654 100644 --- a/src/error.rs +++ b/src/error.rs @@ -539,3 +539,9 @@ impl From for Error { panic!("INFALLIBLE") } } + +impl From for std::io::Error { + fn from(e: Error) -> std::io::Error { + std::io::Error::other(e) + } +} From 1382fc397c6380dbec38d3608f3812a2d4fbe37b Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sat, 16 Nov 2024 20:27:16 +1300 Subject: [PATCH 02/21] Error::FromHex(FromHexError) --- src/error.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/error.rs b/src/error.rs index ce8a654..63fa1dd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -68,6 +68,9 @@ pub enum ChorusError { // Event is Invalid EventIsInvalid(String), + // From hex + FromHex(hex::FromHexError), + // From UTF8 FromUtf8(std::string::FromUtf8Error), @@ -182,6 +185,7 @@ impl std::fmt::Display for ChorusError { ChorusError::Crypto(e) => write!(f, "{e}"), ChorusError::ErrorClose => write!(f, "Closing due to error(s)"), ChorusError::EventIsInvalid(s) => write!(f, "Event is invalid: {s}"), + ChorusError::FromHex(e) => write!(f, "{e}"), ChorusError::FromUtf8(e) => write!(f, "{e}"), ChorusError::General(s) => write!(f, "{s}"), ChorusError::Http(e) => write!(f, "{e}"), @@ -225,6 +229,7 @@ impl StdError for ChorusError { ChorusError::ChannelSend(e) => Some(e), ChorusError::Config(e) => Some(e), ChorusError::Crypto(e) => Some(e), + ChorusError::FromHex(e) => Some(e), ChorusError::FromUtf8(e) => Some(e), ChorusError::Http(e) => Some(e), ChorusError::Hyper(e) => Some(e), @@ -272,6 +277,7 @@ impl ChorusError { ChorusError::Crypto(_) => 0.1, ChorusError::ErrorClose => 1.0, ChorusError::EventIsInvalid(_) => 0.2, + ChorusError::FromHex(_) => 0.2, ChorusError::FromUtf8(_) => 0.2, ChorusError::General(_) => 0.0, ChorusError::Http(_) => 0.0, @@ -514,6 +520,16 @@ impl From for Error { } } +impl From for Error { + #[track_caller] + fn from(err: hex::FromHexError) -> Self { + Error { + inner: ChorusError::FromHex(err), + location: std::panic::Location::caller(), + } + } +} + impl From for Error { #[track_caller] fn from(err: std::string::FromUtf8Error) -> Self { From bcb7fc84b6ca1e781390b27c1688f6f5fc217d4c Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sat, 16 Nov 2024 20:13:26 +1300 Subject: [PATCH 03/21] Depend on bitcoin_hashes, tokio-util, tokio-stream --- Cargo.lock | 34 ++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +++ 2 files changed, 37 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a5f6d14..281716f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +[[package]] +name = "bitcoin-io" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" + [[package]] name = "bitcoin_hashes" version = "0.13.0" @@ -195,6 +201,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ + "bitcoin-io", "hex-conservative 0.2.1", ] @@ -259,6 +266,7 @@ name = "chorus" version = "1.5.3" dependencies = [ "base64 0.22.1", + "bitcoin_hashes 0.14.0", "dashmap", "env_logger", "futures", @@ -283,6 +291,8 @@ dependencies = [ "textnonce", "tokio", "tokio-rustls", + "tokio-stream", + "tokio-util", "toml", "url", ] @@ -1827,6 +1837,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -1839,6 +1860,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.19" diff --git a/Cargo.toml b/Cargo.toml index 67d22d3..39281ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" [dependencies] base64 = "0.22" +bitcoin_hashes = { version = "0.14", features = [ "bitcoin-io" ] } dashmap = "6" env_logger = "0.11" futures = "0.3" @@ -32,6 +33,8 @@ speedy = "0.8" textnonce = "1" tokio = { version = "1", features = [ "full" ] } tokio-rustls = "0.26" +tokio-util = { version = "0.7", features = [ "io" ] } +tokio-stream = "0.1" toml = "0.8" url = "2.5" From 853fefbdf6d995b39c52ae85c6b1bf52fd4d8ad5 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sat, 16 Nov 2024 20:15:43 +1300 Subject: [PATCH 04/21] FileStore (and HashOutput) --- src/filestore/hash_output.rs | 61 ++++++++++++++++ src/filestore/mod.rs | 137 +++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 199 insertions(+) create mode 100644 src/filestore/hash_output.rs create mode 100644 src/filestore/mod.rs diff --git a/src/filestore/hash_output.rs b/src/filestore/hash_output.rs new file mode 100644 index 0000000..dc59480 --- /dev/null +++ b/src/filestore/hash_output.rs @@ -0,0 +1,61 @@ +use crate::{ChorusError, Error}; +use std::fmt; +use std::path::{Path, PathBuf}; + +/// A simple type for a SHA-256 hash output of 32 bytes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct HashOutput([u8; 32]); + +impl HashOutput { + pub fn from_engine(engine: bitcoin_hashes::sha256::HashEngine) -> HashOutput { + use bitcoin_hashes::{sha256, Hash}; + let hashvalue = sha256::Hash::from_engine(engine); + HashOutput(hashvalue.as_byte_array()[0..32].try_into().unwrap()) + } + + pub fn from_hex(input: &str) -> Result { + let bytes = hex::decode(input)?; + if bytes.len() == 32 { + Ok(HashOutput(bytes.try_into().unwrap())) + } else { + Err( + ChorusError::General("HashOutput::from_hex() got wrong length string".to_string()) + .into(), + ) + } + } + + pub fn to_pathbuf>(&self, base: P) -> PathBuf { + let s = hex::encode(self.0); + let mut output: PathBuf = PathBuf::new(); + output.push(base); + output.push(&s[0..=1]); + output.push(&s[2..=3]); + output.push(&s[4..]); + output + } +} + +impl fmt::Display for HashOutput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_hash_output_to_pathbuf() { + let hash = HashOutput::from_hex( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ) + .unwrap(); + + assert_eq!( + &format!("{}", hash.to_pathbuf("/tmp").display()), + "/tmp/e3/b0/c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } +} diff --git a/src/filestore/mod.rs b/src/filestore/mod.rs new file mode 100644 index 0000000..7fe4a5d --- /dev/null +++ b/src/filestore/mod.rs @@ -0,0 +1,137 @@ +use crate::error::Error; +use futures::TryStreamExt; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyDataStream, BodyExt, StreamBody}; +use hyper::body::{Bytes, Frame}; +use std::fs::Metadata; +use std::path::{Path, PathBuf}; +use tokio::fs; +use tokio::fs::File; +use tokio_util::io::{InspectReader, ReaderStream, StreamReader}; + +mod hash_output; +pub use hash_output::HashOutput; + +pub struct FileStore { + pub base: PathBuf, + pub temp: PathBuf, +} + +impl FileStore { + pub async fn new>(base: P) -> Result { + let base = base.as_ref().to_owned(); + + let temp = { + let mut temp = base.clone(); + temp.push("temp"); + temp + }; + + if !fs::try_exists(&temp).await? { + fs::create_dir_all(&temp).await?; + } + + Ok(FileStore { base, temp }) + } + + fn tmpfile(&self) -> PathBuf { + let mut tf = self.temp.clone(); + let nonce = textnonce::TextNonce::new(); + tf.push(&nonce.0); + tf + } + + /// Store a file in storage, streamed from a hyper BoxBody + /// + /// Returns it's HashOutput by which it can be later retrieved or deleted. + pub async fn store(&self, data: BoxBody) -> Result { + use bitcoin_hashes::sha256; + use std::io::Write; // for hash_engine.write_all() + + // We will download into a temporary file (as we don't know the hash yet) + let temppathbuf = self.tmpfile(); + let mut tempfile = File::options() + .create(true) + .truncate(true) + .write(true) + .open(&temppathbuf) + .await?; + + // Convert the BoxBody into a Data Stream + let body_stream = BodyDataStream::new(data); + + // Convert the Data Stream into something that is AsyncRead (over bytes) + let stream_reader = StreamReader::new(body_stream); + + // Wrap this in something that lets us inspect the content so we can hash it + // as it comes in + let mut hash_engine = sha256::HashEngine::default(); + let mut inspect_reader = InspectReader::new(stream_reader, |bytes: &[u8]| { + hash_engine.write_all(bytes).unwrap(); // I don't think hashing will fail + }); + + // Copy the data into the tempfile (hashing as we go) + tokio::io::copy(&mut inspect_reader, &mut tempfile).await?; + + // Finish the hash + let hash = HashOutput::from_engine(hash_engine); + + // Compute the proper path + let pathbuf = hash.to_pathbuf(&self.base); + + // If it already exists + if fs::try_exists(&pathbuf).await? { + // Just clean up + fs::remove_file(&temppathbuf).await?; + + return Ok(hash); + } + + // Make the parent directory + fs::create_dir_all(pathbuf.parent().unwrap()).await?; + + // Move the file + fs::rename(&temppathbuf, &pathbuf).await?; + + Ok(hash) + } + + /// Retrieve a file from storage by its HashOutput, streamed to a hyper BoxBoxy + pub async fn retrieve(&self, hash: HashOutput) -> Result, Error> { + // Compute the path + let pathbuf = hash.to_pathbuf(&self.base); + + // Open the file + let file = File::open(&pathbuf).await?; + + // Convert the AsyncRead file into a Stream + let reader_stream = ReaderStream::new(file); + + // Convert the Stream into a Body + let stream_body = StreamBody::new(reader_stream.map_ok(Frame::data)); + + // Box the body, mapping the error + let boxed_body = BodyExt::map_err(stream_body, |e| e.into()).boxed(); + + Ok(boxed_body) + } + + /// Check if a file exists and provide it's metadata (including .len()) + pub async fn metadata(&self, hash: HashOutput) -> Result { + // Compute the path + let pathbuf = hash.to_pathbuf(&self.base); + + Ok(tokio::fs::metadata(&pathbuf).await?) + } + + /// Delete a file from storage by its HashOutput + pub async fn delete(&self, hash: HashOutput) -> Result<(), Error> { + // Compute the path + let pathbuf = hash.to_pathbuf(&self.base); + + // Delete the file + tokio::fs::remove_file(&pathbuf).await?; + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 859750b..e3bb337 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod counting_stream; pub mod error; +pub mod filestore; pub mod globals; pub mod ip; pub mod nostr; From 8b90d83dd5bcefdb2179e2e960efae45603583fa Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sat, 16 Nov 2024 20:33:54 +1300 Subject: [PATCH 05/21] config.blossom_directory --- contrib/chorus.toml | 13 +++++++++++++ docs/CONFIG.md | 12 ++++++++++++ src/config.rs | 5 +++++ 3 files changed, 30 insertions(+) diff --git a/contrib/chorus.toml b/contrib/chorus.toml index 2108951..e360bd6 100644 --- a/contrib/chorus.toml +++ b/contrib/chorus.toml @@ -299,3 +299,16 @@ throttling_bytes_per_second = 1024576 # Default is 16777216 bytes. # throttling_burst = 16777216 + + +# Blossom server directory +# +# Set to a filesystem directory where you want chorus to store files. +# +# Blossom allows clients to upload files making them available for the public to download. +# Our implementation makes all files publicly readable, but only chorus users can upload +# or delete. See https://github.com/hzrd149/blossom +# +# Default is not set +# +# blossom_directory = diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 7d58c91..3de5492 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -257,3 +257,15 @@ second. If that bucket doesn't have enough, the burst won't be allowed and the c will be closed. Default is 16777216 bytes. + +### blossom_directory + +Blossom server directory + +Set to a filesystem directory where you want chorus to store files. + +Blossom allows clients to upload files making them available for the public to download. +Our implementation makes all files publicly readable, but only chorus users can upload +or delete. See https://github.com/hzrd149/blossom + +Default is None diff --git a/src/config.rs b/src/config.rs index d6d3450..ecbb13e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,7 @@ pub struct FriendlyConfig { pub max_connections_per_ip: usize, pub throttling_bytes_per_second: usize, pub throttling_burst: usize, + pub blossom_directory: Option, } impl Default for FriendlyConfig { @@ -77,6 +78,7 @@ impl Default for FriendlyConfig { max_connections_per_ip: 5, throttling_bytes_per_second: 1024 * 1024, throttling_burst: 1024 * 1024 * 16, + blossom_directory: None, } } } @@ -116,6 +118,7 @@ impl FriendlyConfig { max_connections_per_ip, throttling_bytes_per_second, throttling_burst, + blossom_directory, } = self; let mut public_key: Option = None; @@ -177,6 +180,7 @@ impl FriendlyConfig { max_connections_per_ip, throttling_bytes_per_second, throttling_burst, + blossom_directory, }) } } @@ -217,6 +221,7 @@ pub struct Config { pub max_connections_per_ip: usize, pub throttling_bytes_per_second: usize, pub throttling_burst: usize, + pub blossom_directory: Option, } impl Default for Config { From d526f103c7f056d133fe7bd5ded107ee3ced09dc Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 09:16:22 +1300 Subject: [PATCH 06/21] GLOBALS.filestore --- src/bin/chorus.rs | 5 +++++ src/globals.rs | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/bin/chorus.rs b/src/bin/chorus.rs index 8363a8e..ff93571 100644 --- a/src/bin/chorus.rs +++ b/src/bin/chorus.rs @@ -31,6 +31,11 @@ async fn main() -> Result<(), Error> { let store = chorus::setup_store(&config)?; let _ = GLOBALS.store.set(store); + if let Some(ref blossom_directory) = config.blossom_directory { + let filestore = chorus::filestore::FileStore::new(blossom_directory).await?; + let _ = GLOBALS.filestore.set(filestore); + } + // TLS setup let maybe_tls_acceptor = if config.use_tls { log::info!(target: "Server", "Using TLS"); diff --git a/src/globals.rs b/src/globals.rs index b9de435..1940e36 100644 --- a/src/globals.rs +++ b/src/globals.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::filestore::FileStore; use crate::ip::HashedIp; use dashmap::DashMap; use hyper::server::conn::http1; @@ -18,6 +19,7 @@ pub struct Globals { pub bytes_outbound: AtomicU64, pub config: RwLock, pub store: OnceLock, + pub filestore: OnceLock, pub http1builder: http1::Builder, pub rid: OnceLock, @@ -48,6 +50,7 @@ lazy_static! { bytes_outbound: AtomicU64::new(0), config: RwLock::new(Default::default()), store: OnceLock::new(), + filestore: OnceLock::new(), http1builder, rid: OnceLock::new(), new_events, From 28fb96a9d58f5377ca75cca6a5eceabc2bf39eeb Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 14:04:13 +1300 Subject: [PATCH 07/21] 3 new error variants needed for blossom work --- src/error.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 63fa1dd..59be098 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,7 +23,7 @@ impl std::fmt::Display for Error { /// Errors that can occur in the chorus crate #[derive(Debug)] pub enum ChorusError { - // Auth failure + // Nostr AUTH failure AuthFailure(String), // Auth required @@ -50,6 +50,9 @@ pub enum ChorusError { // Blocked IP BlockedIp, + // Blossom Authorization failure + BlossomAuthFailure(String), + // Channel Recv ChannelRecv(tokio::sync::broadcast::error::RecvError), @@ -101,6 +104,9 @@ pub enum ChorusError { // Missing Table MissingTable(&'static str), + // Non-ASCII HTTP header value + NonAsciiHttpHeaderValue(http::header::ToStrError), + // No private key NoPrivateKey, @@ -140,6 +146,9 @@ pub enum ChorusError { // Serde JSON SerdeJson(serde_json::Error), + // Signal - Not Blossom Request + SignalNotBlossom, + // Speedy Speedy(speedy::Error), @@ -179,6 +188,7 @@ impl std::fmt::Display for ChorusError { ChorusError::BannedUser => write!(f, "User is banned"), ChorusError::Base64Decode(e) => write!(f, "{e}"), ChorusError::BlockedIp => write!(f, "IP is temporarily blocked"), + ChorusError::BlossomAuthFailure(s) => write!(f, "Authorization failure: {s}"), ChorusError::ChannelRecv(e) => write!(f, "{e}"), ChorusError::ChannelSend(e) => write!(f, "{e}"), ChorusError::Config(e) => write!(f, "{e}"), @@ -196,6 +206,9 @@ impl std::fmt::Display for ChorusError { ChorusError::Io(e) => write!(f, "{e}"), ChorusError::ManagementAuthFailure(s) => write!(f, "Authorization failure: {s}"), ChorusError::MissingTable(t) => write!(f, "Missing table: {t}"), + ChorusError::NonAsciiHttpHeaderValue(e) => { + write!(f, "Non ASCII HTTP header value: {e}") + } ChorusError::NoPrivateKey => write!(f, "Private Key Not Found"), ChorusError::NotImplemented => write!(f, "Not implemented"), ChorusError::NoSuchSubscription => write!(f, "No such subscription"), @@ -209,6 +222,7 @@ impl std::fmt::Display for ChorusError { ChorusError::Rustls(e) => write!(f, "{e}"), ChorusError::Scraper => write!(f, "Filter is underspecified. Scrapers are not allowed"), ChorusError::SerdeJson(e) => write!(f, "{e}"), + ChorusError::SignalNotBlossom => write!(f, "internal-signal-not-blossom"), ChorusError::Speedy(e) => write!(f, "{e}"), ChorusError::TimedOut => write!(f, "Timed out"), ChorusError::TooManySubscriptions => write!(f, "Too many subscriptions"), @@ -236,6 +250,7 @@ impl StdError for ChorusError { ChorusError::InvalidUri(e) => Some(e), ChorusError::InvalidUriParts(e) => Some(e), ChorusError::Io(e) => Some(e), + ChorusError::NonAsciiHttpHeaderValue(e) => Some(e), ChorusError::PocketDb(e) => Some(e), ChorusError::PocketDbHeed(e) => Some(e), ChorusError::PocketType(e) => Some(e), @@ -271,6 +286,7 @@ impl ChorusError { ChorusError::BannedUser => 0.2, ChorusError::Base64Decode(_) => 0.0, ChorusError::BlockedIp => 0.0, + ChorusError::BlossomAuthFailure(_) => 0.0, ChorusError::ChannelRecv(_) => 0.0, ChorusError::ChannelSend(_) => 0.0, ChorusError::Config(_) => 0.0, @@ -288,6 +304,7 @@ impl ChorusError { ChorusError::Io(_) => 0.0, ChorusError::ManagementAuthFailure(_) => 0.0, ChorusError::MissingTable(_) => 0.0, + ChorusError::NonAsciiHttpHeaderValue(_) => 0.2, ChorusError::NoPrivateKey => 0.0, ChorusError::NotImplemented => 0.0, ChorusError::NoSuchSubscription => 0.05, @@ -301,6 +318,7 @@ impl ChorusError { ChorusError::Rustls(_) => 0.0, ChorusError::Scraper => 0.4, ChorusError::SerdeJson(_) => 0.0, + ChorusError::SignalNotBlossom => 0.0, ChorusError::Speedy(_) => 0.0, ChorusError::TimedOut => 0.1, ChorusError::TooManySubscriptions => 0.1, @@ -420,6 +438,16 @@ impl From for Error { } } +impl From for Error { + #[track_caller] + fn from(err: http::header::ToStrError) -> Self { + Error { + inner: ChorusError::NonAsciiHttpHeaderValue(err), + location: std::panic::Location::caller(), + } + } +} + impl From for Error { #[track_caller] fn from(err: pocket_db::Error) -> Self { From da4ec4b5c8e606079a0286d4d3c78469a3efe2c5 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 14:07:22 +1300 Subject: [PATCH 08/21] Blossom stubbed out --- src/web/blossom/mod.rs | 136 +++++++++++++++++++++++++++++++++++++++++ src/web/mod.rs | 16 ++++- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/web/blossom/mod.rs diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs new file mode 100644 index 0000000..f0892bc --- /dev/null +++ b/src/web/blossom/mod.rs @@ -0,0 +1,136 @@ +use crate::error::{ChorusError, Error}; +use http::header::{ + ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, + ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ALLOW, CONTENT_LENGTH, ORIGIN, + WWW_AUTHENTICATE, +}; +use http::{Method, StatusCode}; +//ACCEPT, AUTHORIZATION, CONTENT_TYPE, DATE, ETAG, ORIGIN +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Empty}; +use hyper::body::{Bytes, Incoming}; +use hyper::{Request, Response}; + +pub async fn handle(request: &Request) -> Result>, Error> { + match route(request).await { + Ok(response) => Ok(response), + Err(e) => match e.inner { + ChorusError::SignalNotBlossom => Err(e), + _ => error_response(e), + }, + } +} + +pub async fn route(request: &Request) -> Result>, Error> { + let p = request.uri().path(); + if p.starts_with("/") + && p.len() >= 1 + 64 + && p.chars().skip(1).take(64).all(|c| c.is_ascii_hexdigit()) + { + handle_hash(request).await + } else if p == "/upload" { + handle_upload(request).await + } else if p.starts_with("/list/") + && p.len() >= 6 + 64 + && p.chars().skip(6).take(64).all(|c| c.is_ascii_hexdigit()) + { + handle_list(request).await + } else if p == "/mirror" { + handle_mirror(request).await + } else { + Err(ChorusError::SignalNotBlossom.into()) + } +} + +fn error_response(e: Error) -> Result>, Error> { + use std::io::ErrorKind; + + let mut response = Response::builder().header(ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + + let (status, reason) = match e.inner { + ChorusError::BlossomAuthFailure(m) => { + response = response.header(WWW_AUTHENTICATE, "Nostr"); + (StatusCode::UNAUTHORIZED, m) + } + ChorusError::FromHex(_) => (StatusCode::BAD_REQUEST, format!("{e}")), + ChorusError::Io(ref ioerror) => match ioerror.kind() { + ErrorKind::NotFound => (StatusCode::NOT_FOUND, "Not Found".to_owned()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")), + }, + _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")), + }; + + Ok(response + .header("X-Reason", reason) + .status(status) + .body(Empty::new().map_err(|e| e.into()).boxed())?) +} + +fn options_response( + request: &Request, + methods: &str, +) -> Result>, Error> { + if request + .headers() + .contains_key(ACCESS_CONTROL_REQUEST_HEADERS) + || request + .headers() + .contains_key(ACCESS_CONTROL_REQUEST_METHOD) + || request.headers().contains_key(ORIGIN) + { + // CORS OPTIONS response + Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(ACCESS_CONTROL_ALLOW_HEADERS, "Authorization, *") + .header(ACCESS_CONTROL_ALLOW_METHODS, methods) + .header(CONTENT_LENGTH, "0") + .status(StatusCode::OK) + .body(Empty::new().map_err(|e| e.into()).boxed())?) + } else { + // Normal OPTIONS response + Ok(Response::builder() + .header(ALLOW, methods) + .status(StatusCode::NO_CONTENT) + .body(Empty::new().map_err(|e| e.into()).boxed())?) + } +} + +pub async fn handle_hash( + request: &Request, +) -> Result>, Error> { + if matches!(request.method(), &Method::OPTIONS) { + return options_response(request, "OPTIONS, HEAD, GET, DELETE"); + } + + unimplemented!() +} + +pub async fn handle_upload( + request: &Request, +) -> Result>, Error> { + if matches!(request.method(), &Method::OPTIONS) { + return options_response(request, "OPTIONS, HEAD, PUT"); + } + + unimplemented!() +} + +pub async fn handle_list( + request: &Request, +) -> Result>, Error> { + if matches!(request.method(), &Method::OPTIONS) { + return options_response(request, "OPTIONS, GET"); + } + + unimplemented!() +} + +pub async fn handle_mirror( + request: &Request, +) -> Result>, Error> { + if matches!(request.method(), &Method::OPTIONS) { + return options_response(request, "OPTIONS, PUT"); + } + + unimplemented!() +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 1989c96..b626ccb 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,7 +1,9 @@ +mod blossom; mod management; mod nip11; -use crate::error::Error; +use crate::error::{ChorusError, Error}; +use crate::globals::GLOBALS; use crate::ip::HashedPeer; use http::Method; use http_body_util::combinators::BoxBody; @@ -41,6 +43,18 @@ pub async fn serve_http( } } + // Try blossom if enabled + if GLOBALS.config.read().blossom_directory.is_some() { + match blossom::handle(&request).await { + Ok(response) => return Ok(response), + Err(e) => { + if !matches!(e.inner, ChorusError::SignalNotBlossom) { + return Err(e); + } + } + } + } + log::debug!(target: "Client", "{}: HTTP request for {}", peer, request.uri()); let response = Response::builder() .header("Access-Control-Allow-Origin", "*") From bcb0fc1c95f319dabc116bb068f4437c0bb12671 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 14:08:57 +1300 Subject: [PATCH 09/21] Blossom Auth --- src/web/blossom/auth.rs | 146 ++++++++++++++++++++++++++++++++++++++++ src/web/blossom/mod.rs | 11 +++ 2 files changed, 157 insertions(+) create mode 100644 src/web/blossom/auth.rs diff --git a/src/web/blossom/auth.rs b/src/web/blossom/auth.rs new file mode 100644 index 0000000..c5499c6 --- /dev/null +++ b/src/web/blossom/auth.rs @@ -0,0 +1,146 @@ +use crate::error::{ChorusError, Error}; +use crate::globals::GLOBALS; +use base64::prelude::*; +use http::header::AUTHORIZATION; +use hyper::body::Incoming; +use hyper::Request; +use pocket_types::Event; + +fn s_err(s: &str) -> Result { + Err(ChorusError::BlossomAuthFailure(s.to_owned()).into()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AuthVerb { + Upload, + List, + Delete, + Mirror, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AuthData { + /// If a verb was included, this is it + pub verb: Option, + + /// If an 'x' tag was included, this is the hash + pub hash: Option<[u8; 32]>, +} + +pub fn verify_auth(request: &Request) -> Result { + // Force every other error into a BlossomAuthFailure error + match verify_auth_inner(request) { + Ok(ad) => Ok(ad), + Err(e) => match e.inner { + ChorusError::BlossomAuthFailure(_) => return Err(e), + _ => return Err(ChorusError::BlossomAuthFailure(format!("{e}")).into()), + }, + } +} + +fn verify_auth_inner(request: &Request) -> Result { + // Must have AUTHORIZATION header + let authz = match request.headers().get(AUTHORIZATION) { + Some(h) => h, + None => return s_err("Authorization Required"), + }; + + // Authorization header must be type "nostr" + if !authz.to_str()?.to_ascii_lowercase().starts_with("nostr ") { + return s_err("You must use the Nostr authorization scheme"); + } + + let base64 = match authz.to_str()?.get(6..) { + Some(x) => x, + None => return s_err("Missing auth base64 encoded event"), + }; + + // Authorization header must be base64 + let event_bytes = BASE64_STANDARD.decode(base64)?; + + // Authorization header base64 must decode to a nostr Event + let mut buffer = vec![0; base64.len()]; + let (_size, event) = Event::from_json(&event_bytes, &mut buffer)?; + + // Nostr event must be valid + if let Err(e) = event.verify() { + return s_err(&format!("Authorization event is invalid: {}", e)); + } + + // Nostr event must be signed by a chorus user + if !GLOBALS.config.read().user_keys.contains(&event.pubkey()) { + return s_err("You are not an authorized user"); + } + + // Event kind must be 24242 + if event.kind().as_u16() != 24242 { + return s_err("Authorization event not kind 24242"); + } + + // Event created_at must be in the past (we give 30 seconds leeway) + use pocket_types::Time; + let now = Time::now(); + if event.created_at() > now + 30 { + return s_err("Authorization event too far in the future"); + } + + let tags = event.tags()?; + + // Expiration tag must be in the future + if let Some(v) = tags.get_value(b"expiration") { + let u = parse_u64(v)?; + let expiration = Time::from_u64(u); + if expiration < now { + return s_err("Authorization event has expired"); + } + } else { + return s_err("Authorization event missing expiration tag"); + } + + // We let the caller check the verb and hash since those are specific + // to the endpoint (and the 'x' must be checked later on) + + let verb: Option = if let Some(t) = tags.get_value(b"t") { + if t == b"upload" { + Some(AuthVerb::Upload) + } else if t == b"list" { + Some(AuthVerb::List) + } else if t == b"delete" { + Some(AuthVerb::Delete) + } else { + None + } + } else { + None + }; + + let hash: Option<[u8; 32]> = if let Some(v) = tags.get_value(b"x") { + let vec = hex::decode(v)?; + if vec.len() == 32 { + Some(vec.try_into().unwrap()) + } else { + return s_err("Authorization event x tag is of the wrong length"); + } + } else { + None + }; + + Ok(AuthData { verb, hash }) +} + +// FIXME, expose these from pocket-types +fn parse_u64(input: &[u8]) -> Result { + let mut pos = 0; + let mut value: u64 = 0; + let mut any: bool = false; + while pos < input.len() && b"0123456789".contains(&input[pos]) { + any = true; + value = (value * 10) + (input[pos] - 48) as u64; + pos += 1; + } + if !any { + Err(ChorusError::General("Auth event expiration is not a number".to_string()).into()) + } else { + Ok(value) + } +} diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index f0892bc..7a534f7 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -11,6 +11,9 @@ use http_body_util::{BodyExt, Empty}; use hyper::body::{Bytes, Incoming}; use hyper::{Request, Response}; +mod auth; +use auth::verify_auth; + pub async fn handle(request: &Request) -> Result>, Error> { match route(request).await { Ok(response) => Ok(response), @@ -102,6 +105,8 @@ pub async fn handle_hash( return options_response(request, "OPTIONS, HEAD, GET, DELETE"); } + let _auth_data = verify_auth(request)?; + unimplemented!() } @@ -112,6 +117,8 @@ pub async fn handle_upload( return options_response(request, "OPTIONS, HEAD, PUT"); } + let _auth_data = verify_auth(request)?; + unimplemented!() } @@ -122,6 +129,8 @@ pub async fn handle_list( return options_response(request, "OPTIONS, GET"); } + let _auth_data = verify_auth(request)?; + unimplemented!() } @@ -132,5 +141,7 @@ pub async fn handle_mirror( return options_response(request, "OPTIONS, PUT"); } + let _auth_data = verify_auth(request)?; + unimplemented!() } From 3f26b4700892eaa07cb9e1d1020b506e46297a1a Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 14:25:00 +1300 Subject: [PATCH 10/21] Blossom: implement GET/HEAD/DELETE / --- src/web/blossom/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index 7a534f7..d9249e1 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -1,4 +1,6 @@ use crate::error::{ChorusError, Error}; +use crate::filestore::HashOutput; +use crate::globals::GLOBALS; use http::header::{ ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ALLOW, CONTENT_LENGTH, ORIGIN, @@ -12,7 +14,7 @@ use hyper::body::{Bytes, Incoming}; use hyper::{Request, Response}; mod auth; -use auth::verify_auth; +use auth::{verify_auth, AuthVerb}; pub async fn handle(request: &Request) -> Result>, Error> { match route(request).await { @@ -105,9 +107,52 @@ pub async fn handle_hash( return options_response(request, "OPTIONS, HEAD, GET, DELETE"); } - let _auth_data = verify_auth(request)?; + // HEAD, GET, DELETE + let p = request.uri().path(); + let hashstr: String = p.chars().skip(1).take(64).collect(); + let hash = match HashOutput::from_hex(&hashstr) { + Ok(h) => h, + Err(e) => return error_response(e), + }; - unimplemented!() + let metadata = GLOBALS.filestore.get().unwrap().metadata(hash).await?; + + match request.method() { + &Method::HEAD => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, format!("{}", metadata.len())) + .status(StatusCode::OK) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + &Method::GET => { + let body = GLOBALS.filestore.get().unwrap().retrieve(hash).await?; + Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, format!("{}", metadata.len())) + .status(StatusCode::OK) + .body(body)?) + } + &Method::DELETE => { + let auth_data = verify_auth(request)?; + if auth_data.verb != Some(AuthVerb::Delete) { + return Err(ChorusError::BlossomAuthFailure( + "Delete was not authorized".to_string(), + ) + .into()); + } + + GLOBALS.filestore.get().unwrap().delete(hash).await?; + Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::OK) + .body(Empty::new().map_err(|e| e.into()).boxed())?) + } + _ => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + } } pub async fn handle_upload( From af1d992ce76e1886730ea5f082c5dd9dc15e3463 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Mon, 18 Nov 2024 08:30:30 +1300 Subject: [PATCH 11/21] Change config.url() to config.uri_parts() so that (later) path_and_query can be swapped out --- src/config.rs | 4 ++-- src/web/management/auth.rs | 6 +++++- src/web/mod.rs | 5 ++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index ecbb13e..dd83e77 100644 --- a/src/config.rs +++ b/src/config.rs @@ -234,7 +234,7 @@ impl Default for Config { } impl Config { - pub fn url(&self, inner: Uri, http: bool) -> Result { + pub fn uri_parts(&self, inner: Uri, http: bool) -> Result { let mut uri_parts = inner.into_parts(); let scheme = match (self.use_tls, http) { (false, false) => Scheme::from_str("ws").unwrap(), @@ -245,6 +245,6 @@ impl Config { uri_parts.scheme = Some(scheme); let authority = Authority::from_str(&format!("{}:{}", self.hostname, self.port))?; uri_parts.authority = Some(authority); - Ok(Uri::from_parts(uri_parts)?) + Ok(uri_parts) } } diff --git a/src/web/management/auth.rs b/src/web/management/auth.rs index d33932b..bc0816b 100644 --- a/src/web/management/auth.rs +++ b/src/web/management/auth.rs @@ -86,7 +86,11 @@ pub async fn check_auth(request: Request) -> Result { if let Some(u) = tags.get_value(b"u") { let auth_url = String::from_utf8(u.to_owned())?; let actual_url = { - let uri = GLOBALS.config.read().url(request.uri().to_owned(), true)?; + let uri_parts = GLOBALS + .config + .read() + .uri_parts(request.uri().to_owned(), true)?; + let uri = http::Uri::from_parts(uri_parts)?; format!("{}", uri) }; diff --git a/src/web/mod.rs b/src/web/mod.rs index b626ccb..23c5775 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -43,6 +43,8 @@ pub async fn serve_http( } } + let uri = request.uri().to_owned(); + // Try blossom if enabled if GLOBALS.config.read().blossom_directory.is_some() { match blossom::handle(&request).await { @@ -55,7 +57,8 @@ pub async fn serve_http( } } - log::debug!(target: "Client", "{}: HTTP request for {}", peer, request.uri()); + log::debug!(target: "Client", "{}: HTTP request for {}", peer, uri); + let response = Response::builder() .header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Headers", "*") From 7b60e267146cb8ea8a839b92e6feb4903bc9ae91 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Mon, 18 Nov 2024 08:32:03 +1300 Subject: [PATCH 12/21] HashOutput::from_bytes() --- src/filestore/hash_output.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/filestore/hash_output.rs b/src/filestore/hash_output.rs index dc59480..b34f030 100644 --- a/src/filestore/hash_output.rs +++ b/src/filestore/hash_output.rs @@ -25,6 +25,10 @@ impl HashOutput { } } + pub fn from_bytes(bytes: [u8; 32]) -> HashOutput { + HashOutput(bytes) + } + pub fn to_pathbuf>(&self, base: P) -> PathBuf { let s = hex::encode(self.0); let mut output: PathBuf = PathBuf::new(); From 8282811087045b9838dfd5697da272729a0fe3c2 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Mon, 18 Nov 2024 08:32:48 +1300 Subject: [PATCH 13/21] Filestore::store(): take expected hash and validate with it, and also return the size --- src/filestore/mod.rs | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/filestore/mod.rs b/src/filestore/mod.rs index 7fe4a5d..3b43dac 100644 --- a/src/filestore/mod.rs +++ b/src/filestore/mod.rs @@ -1,4 +1,4 @@ -use crate::error::Error; +use crate::error::{ChorusError, Error}; use futures::TryStreamExt; use http_body_util::combinators::BoxBody; use http_body_util::{BodyDataStream, BodyExt, StreamBody}; @@ -44,7 +44,11 @@ impl FileStore { /// Store a file in storage, streamed from a hyper BoxBody /// /// Returns it's HashOutput by which it can be later retrieved or deleted. - pub async fn store(&self, data: BoxBody) -> Result { + pub async fn store( + &self, + data: BoxBody, + expected_hash: Option, + ) -> Result<(u64, HashOutput), Error> { use bitcoin_hashes::sha256; use std::io::Write; // for hash_engine.write_all() @@ -63,28 +67,44 @@ impl FileStore { // Convert the Data Stream into something that is AsyncRead (over bytes) let stream_reader = StreamReader::new(body_stream); - // Wrap this in something that lets us inspect the content so we can hash it - // as it comes in + // Wrap this in something that lets us inspect the content so we can + // hash it as it comes in, as well as count the size + let mut size: u64 = 0; let mut hash_engine = sha256::HashEngine::default(); let mut inspect_reader = InspectReader::new(stream_reader, |bytes: &[u8]| { + size += bytes.len() as u64; hash_engine.write_all(bytes).unwrap(); // I don't think hashing will fail }); - // Copy the data into the tempfile (hashing as we go) + // Copy the data into the tempfile (hashing and counting as we go) tokio::io::copy(&mut inspect_reader, &mut tempfile).await?; // Finish the hash let hash = HashOutput::from_engine(hash_engine); + // Verify the expected hash matches + if let Some(expected) = expected_hash { + if hash != expected { + // Remove the temporary file + fs::remove_file(&temppathbuf).await?; + + // And complain + return Err(ChorusError::BlossomAuthFailure( + "File hash does not match authorized hash".to_string(), + ) + .into()); + } + } + // Compute the proper path let pathbuf = hash.to_pathbuf(&self.base); - // If it already exists + // If it already exists, trust the existing copy if fs::try_exists(&pathbuf).await? { // Just clean up fs::remove_file(&temppathbuf).await?; - return Ok(hash); + return Ok((size, hash)); } // Make the parent directory @@ -93,7 +113,7 @@ impl FileStore { // Move the file fs::rename(&temppathbuf, &pathbuf).await?; - Ok(hash) + Ok((size, hash)) } /// Retrieve a file from storage by its HashOutput, streamed to a hyper BoxBoxy From da91fcb27129e9261f9ac445510672d3c63716d6 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 15:09:50 +1300 Subject: [PATCH 14/21] Pass the request (not a reference) into blossom so it can pull the body --- src/web/blossom/mod.rs | 22 +++++++++++----------- src/web/mod.rs | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index d9249e1..8f3a6b6 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -16,7 +16,7 @@ use hyper::{Request, Response}; mod auth; use auth::{verify_auth, AuthVerb}; -pub async fn handle(request: &Request) -> Result>, Error> { +pub async fn handle(request: Request) -> Result>, Error> { match route(request).await { Ok(response) => Ok(response), Err(e) => match e.inner { @@ -26,7 +26,7 @@ pub async fn handle(request: &Request) -> Result) -> Result>, Error> { +pub async fn route(request: Request) -> Result>, Error> { let p = request.uri().path(); if p.starts_with("/") && p.len() >= 1 + 64 @@ -72,7 +72,7 @@ fn error_response(e: Error) -> Result>, Error> { } fn options_response( - request: &Request, + request: Request, methods: &str, ) -> Result>, Error> { if request @@ -101,7 +101,7 @@ fn options_response( } pub async fn handle_hash( - request: &Request, + request: Request, ) -> Result>, Error> { if matches!(request.method(), &Method::OPTIONS) { return options_response(request, "OPTIONS, HEAD, GET, DELETE"); @@ -132,7 +132,7 @@ pub async fn handle_hash( .body(body)?) } &Method::DELETE => { - let auth_data = verify_auth(request)?; + let auth_data = verify_auth(&request)?; if auth_data.verb != Some(AuthVerb::Delete) { return Err(ChorusError::BlossomAuthFailure( "Delete was not authorized".to_string(), @@ -156,37 +156,37 @@ pub async fn handle_hash( } pub async fn handle_upload( - request: &Request, + request: Request, ) -> Result>, Error> { if matches!(request.method(), &Method::OPTIONS) { return options_response(request, "OPTIONS, HEAD, PUT"); } - let _auth_data = verify_auth(request)?; + let _auth_data = verify_auth(&request)?; unimplemented!() } pub async fn handle_list( - request: &Request, + request: Request, ) -> Result>, Error> { if matches!(request.method(), &Method::OPTIONS) { return options_response(request, "OPTIONS, GET"); } - let _auth_data = verify_auth(request)?; + let _auth_data = verify_auth(&request)?; unimplemented!() } pub async fn handle_mirror( - request: &Request, + request: Request, ) -> Result>, Error> { if matches!(request.method(), &Method::OPTIONS) { return options_response(request, "OPTIONS, PUT"); } - let _auth_data = verify_auth(request)?; + let _auth_data = verify_auth(&request)?; unimplemented!() } diff --git a/src/web/mod.rs b/src/web/mod.rs index 23c5775..8dd3490 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -47,7 +47,7 @@ pub async fn serve_http( // Try blossom if enabled if GLOBALS.config.read().blossom_directory.is_some() { - match blossom::handle(&request).await { + match blossom::handle(request).await { Ok(response) => return Ok(response), Err(e) => { if !matches!(e.inner, ChorusError::SignalNotBlossom) { From 46605fae2dcb02d573cdb3bf01a1be412c6cde32 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Mon, 18 Nov 2024 08:37:15 +1300 Subject: [PATCH 15/21] Blossom: Implement HEAD/PUT /upload --- src/web/blossom/mod.rs | 90 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index 8f3a6b6..d692449 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -3,15 +3,16 @@ use crate::filestore::HashOutput; use crate::globals::GLOBALS; use http::header::{ ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, - ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ALLOW, CONTENT_LENGTH, ORIGIN, - WWW_AUTHENTICATE, + ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ALLOW, CONTENT_LENGTH, + CONTENT_TYPE, ORIGIN, WWW_AUTHENTICATE, }; use http::{Method, StatusCode}; -//ACCEPT, AUTHORIZATION, CONTENT_TYPE, DATE, ETAG, ORIGIN +//ACCEPT, AUTHORIZATION, DATE, ETAG, ORIGIN use http_body_util::combinators::BoxBody; -use http_body_util::{BodyExt, Empty}; +use http_body_util::{BodyExt, Empty, Full}; use hyper::body::{Bytes, Incoming}; use hyper::{Request, Response}; +use serde::{Deserialize, Serialize}; mod auth; use auth::{verify_auth, AuthVerb}; @@ -162,9 +163,77 @@ pub async fn handle_upload( return options_response(request, "OPTIONS, HEAD, PUT"); } - let _auth_data = verify_auth(&request)?; + let auth_data = verify_auth(&request)?; + if auth_data.verb != Some(AuthVerb::Upload) { + return Err( + ChorusError::BlossomAuthFailure("Upload was not authorized".to_string()).into(), + ); + } - unimplemented!() + match request.method() { + &Method::HEAD => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::NOT_IMPLEMENTED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + &Method::PUT => { + let expected_hash = auth_data.hash.map(|bytes| HashOutput::from_bytes(bytes)); + if expected_hash.is_none() { + return Err(ChorusError::BlossomAuthFailure( + "Put requires an expected hash value x tag in the authorization event" + .to_string(), + ) + .into()); + } + + let uri = request.uri().to_owned(); + + let (size, hash) = GLOBALS + .filestore + .get() + .unwrap() + .store( + request.into_body().map_err(|e| e.into()).boxed(), + expected_hash, + ) + .await?; + + let uri = { + let mut parts = GLOBALS.config.read().uri_parts(uri, true)?; + parts.path_and_query = Some(http::uri::PathAndQuery::from_maybe_shared(format!( + "/{}", + hash + ))?); + http::Uri::from_parts(parts)? + }; + + let blob_descriptor = BlobDescriptor { + url: format!("{}", uri), + sha256: format!("{}", hash), + size, + uploaded: pocket_types::Time::now().as_u64(), + }; + + let descriptor_json_string = serde_json::to_string(&blob_descriptor)?; + let body_bytes = descriptor_json_string.into_bytes(); + let len = body_bytes.len(); + let body = Full::new(Bytes::from(body_bytes)) + .map_err(|e| e.into()) + .boxed(); + + Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, format!("{}", len)) + .header(CONTENT_TYPE, "application/json") + .status(StatusCode::OK) + .body(body)?) + } + _ => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + } } pub async fn handle_list( @@ -190,3 +259,12 @@ pub async fn handle_mirror( unimplemented!() } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobDescriptor { + pub url: String, + pub sha256: String, + pub size: u64, + // type: String + pub uploaded: u64, +} From 48cc0032a48dd60f5500ad969842ad8fb87a829f Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Mon, 18 Nov 2024 08:45:20 +1300 Subject: [PATCH 16/21] Blossom: stub out /list and /mirror (currently returning NOT_IMPLEMENTED) --- src/web/blossom/mod.rs | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index d692449..9ab5433 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -243,9 +243,23 @@ pub async fn handle_list( return options_response(request, "OPTIONS, GET"); } - let _auth_data = verify_auth(&request)?; + let auth_data = verify_auth(&request)?; + if auth_data.verb != Some(AuthVerb::List) { + return Err(ChorusError::BlossomAuthFailure("List was not authorized".to_string()).into()); + } - unimplemented!() + match request.method() { + &Method::GET => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::NOT_IMPLEMENTED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + _ => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + } } pub async fn handle_mirror( @@ -255,9 +269,25 @@ pub async fn handle_mirror( return options_response(request, "OPTIONS, PUT"); } - let _auth_data = verify_auth(&request)?; + let auth_data = verify_auth(&request)?; + if auth_data.verb != Some(AuthVerb::Mirror) { + return Err( + ChorusError::BlossomAuthFailure("Mirror was not authorized".to_string()).into(), + ); + } - unimplemented!() + match request.method() { + &Method::PUT => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::NOT_IMPLEMENTED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + _ => Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(CONTENT_LENGTH, "0") + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Empty::new().map_err(|e| e.into()).boxed())?), + } } #[derive(Debug, Clone, Serialize, Deserialize)] From 93a2495e325a794d24158cf6e8a3a400802a30ba Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Mon, 18 Nov 2024 08:48:31 +1300 Subject: [PATCH 17/21] blossom related clippy fixes --- src/web/blossom/auth.rs | 4 ++-- src/web/blossom/mod.rs | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/web/blossom/auth.rs b/src/web/blossom/auth.rs index c5499c6..e481716 100644 --- a/src/web/blossom/auth.rs +++ b/src/web/blossom/auth.rs @@ -32,8 +32,8 @@ pub fn verify_auth(request: &Request) -> Result { match verify_auth_inner(request) { Ok(ad) => Ok(ad), Err(e) => match e.inner { - ChorusError::BlossomAuthFailure(_) => return Err(e), - _ => return Err(ChorusError::BlossomAuthFailure(format!("{e}")).into()), + ChorusError::BlossomAuthFailure(_) => Err(e), + _ => Err(ChorusError::BlossomAuthFailure(format!("{e}")).into()), }, } } diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index 9ab5433..4a1372e 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -29,6 +29,7 @@ pub async fn handle(request: Request) -> Result) -> Result>, Error> { let p = request.uri().path(); + #[allow(clippy::int_plus_one)] if p.starts_with("/") && p.len() >= 1 + 64 && p.chars().skip(1).take(64).all(|c| c.is_ascii_hexdigit()) @@ -118,13 +119,13 @@ pub async fn handle_hash( let metadata = GLOBALS.filestore.get().unwrap().metadata(hash).await?; - match request.method() { - &Method::HEAD => Ok(Response::builder() + match *request.method() { + Method::HEAD => Ok(Response::builder() .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(CONTENT_LENGTH, format!("{}", metadata.len())) .status(StatusCode::OK) .body(Empty::new().map_err(|e| e.into()).boxed())?), - &Method::GET => { + Method::GET => { let body = GLOBALS.filestore.get().unwrap().retrieve(hash).await?; Ok(Response::builder() .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") @@ -132,7 +133,7 @@ pub async fn handle_hash( .status(StatusCode::OK) .body(body)?) } - &Method::DELETE => { + Method::DELETE => { let auth_data = verify_auth(&request)?; if auth_data.verb != Some(AuthVerb::Delete) { return Err(ChorusError::BlossomAuthFailure( @@ -170,14 +171,14 @@ pub async fn handle_upload( ); } - match request.method() { - &Method::HEAD => Ok(Response::builder() + match *request.method() { + Method::HEAD => Ok(Response::builder() .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(CONTENT_LENGTH, "0") .status(StatusCode::NOT_IMPLEMENTED) .body(Empty::new().map_err(|e| e.into()).boxed())?), - &Method::PUT => { - let expected_hash = auth_data.hash.map(|bytes| HashOutput::from_bytes(bytes)); + Method::PUT => { + let expected_hash = auth_data.hash.map(HashOutput::from_bytes); if expected_hash.is_none() { return Err(ChorusError::BlossomAuthFailure( "Put requires an expected hash value x tag in the authorization event" @@ -248,8 +249,8 @@ pub async fn handle_list( return Err(ChorusError::BlossomAuthFailure("List was not authorized".to_string()).into()); } - match request.method() { - &Method::GET => Ok(Response::builder() + match *request.method() { + Method::GET => Ok(Response::builder() .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(CONTENT_LENGTH, "0") .status(StatusCode::NOT_IMPLEMENTED) @@ -276,8 +277,8 @@ pub async fn handle_mirror( ); } - match request.method() { - &Method::PUT => Ok(Response::builder() + match *request.method() { + Method::PUT => Ok(Response::builder() .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(CONTENT_LENGTH, "0") .status(StatusCode::NOT_IMPLEMENTED) From de82b109053dad7ea1f4025b81478008388b6e1a Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Tue, 19 Nov 2024 08:21:45 +1300 Subject: [PATCH 18/21] Improve network performance by honoring If-None-Match, If-Modified-Since and even If-Match --- src/web/blossom/mod.rs | 62 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/web/blossom/mod.rs b/src/web/blossom/mod.rs index 4a1372e..7a376a9 100644 --- a/src/web/blossom/mod.rs +++ b/src/web/blossom/mod.rs @@ -4,7 +4,7 @@ use crate::globals::GLOBALS; use http::header::{ ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ALLOW, CONTENT_LENGTH, - CONTENT_TYPE, ORIGIN, WWW_AUTHENTICATE, + CONTENT_TYPE, ETAG, IF_MATCH, IF_MODIFIED_SINCE, IF_NONE_MATCH, ORIGIN, WWW_AUTHENTICATE, }; use http::{Method, StatusCode}; //ACCEPT, AUTHORIZATION, DATE, ETAG, ORIGIN @@ -120,18 +120,58 @@ pub async fn handle_hash( let metadata = GLOBALS.filestore.get().unwrap().metadata(hash).await?; match *request.method() { - Method::HEAD => Ok(Response::builder() - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .header(CONTENT_LENGTH, format!("{}", metadata.len())) - .status(StatusCode::OK) - .body(Empty::new().map_err(|e| e.into()).boxed())?), - Method::GET => { - let body = GLOBALS.filestore.get().unwrap().retrieve(hash).await?; - Ok(Response::builder() + Method::HEAD | Method::GET => { + // Honor If-Match (fail if they didn't specify an etag matching the hash) + if let Some(etags) = request.headers().get(IF_MATCH) { + let mut onematch: bool = false; + for part in etags.to_str()?.split(',') { + if &part[1..part.len() - 1] == &format!("{}", hash) { + onematch = true; + } + } + if !onematch { + return Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(ETAG, format!("\"{}\"", hash)) + .status(StatusCode::PRECONDITION_FAILED) + .body(Empty::new().map_err(|e| e.into()).boxed())?); + } + } + + // Honor If-None-Match (send NOT_MODIFIED if they specified an etag matching the hash) + // Honor If-Modified-Since (always send NOT_MODIFIED) + let mut send_not_modified: bool = false; + if let Some(etags) = request.headers().get(IF_NONE_MATCH) { + for part in etags.to_str()?.split(',') { + if &part[1..part.len() - 1] == &format!("{}", hash) { + send_not_modified = true; + } + } + } + if request.headers().get(IF_MODIFIED_SINCE).is_some() { + send_not_modified = true; + } + if send_not_modified { + return Ok(Response::builder() + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(ETAG, format!("\"{}\"", hash)) + .status(StatusCode::NOT_MODIFIED) + .body(Empty::new().map_err(|e| e.into()).boxed())?); + } + + // Normal reasponse (HEAD or GET) + let response = Response::builder() .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(CONTENT_LENGTH, format!("{}", metadata.len())) - .status(StatusCode::OK) - .body(body)?) + .header(ETAG, format!("\"{}\"", hash)) + .status(StatusCode::OK); + + if matches!(*request.method(), Method::GET) { + let body = GLOBALS.filestore.get().unwrap().retrieve(hash).await?; + Ok(response.body(body)?) + } else { + Ok(response.body(Empty::new().map_err(|e| e.into()).boxed())?) + } } Method::DELETE => { let auth_data = verify_auth(&request)?; From f8d9be7c6db644419274da3046586db97970ae3e Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Tue, 19 Nov 2024 09:31:50 +1300 Subject: [PATCH 19/21] Fix: temporary file naming --- src/filestore/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/filestore/mod.rs b/src/filestore/mod.rs index 3b43dac..e7c991b 100644 --- a/src/filestore/mod.rs +++ b/src/filestore/mod.rs @@ -36,7 +36,7 @@ impl FileStore { fn tmpfile(&self) -> PathBuf { let mut tf = self.temp.clone(); - let nonce = textnonce::TextNonce::new(); + let nonce = textnonce::TextNonce::sized_urlsafe(32).unwrap(); tf.push(&nonce.0); tf } From beb9635e1362cb07eed52b52f246902ee27c04f1 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Tue, 19 Nov 2024 09:46:52 +1300 Subject: [PATCH 20/21] A double check when counting/hashing --- src/filestore/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/filestore/mod.rs b/src/filestore/mod.rs index e7c991b..7d85a48 100644 --- a/src/filestore/mod.rs +++ b/src/filestore/mod.rs @@ -77,7 +77,12 @@ impl FileStore { }); // Copy the data into the tempfile (hashing and counting as we go) - tokio::io::copy(&mut inspect_reader, &mut tempfile).await?; + let count = tokio::io::copy(&mut inspect_reader, &mut tempfile).await?; + + // Verify our code was correct + if count != size { + return Err(ChorusError::General("INTERNAL COUNT MISMATCH".to_string()).into()); + } // Finish the hash let hash = HashOutput::from_engine(hash_engine); From 501edee9a4cbb04ab563c7fd9a6d629dcc84de58 Mon Sep 17 00:00:00 2001 From: Mike Dilger Date: Sun, 17 Nov 2024 14:10:59 +1300 Subject: [PATCH 21/21] Test Blossom --- test_blossom/.gitignore | 3 ++ test_blossom/README.md | 7 +++ test_blossom/avatar-placeholder.webp | Bin 0 -> 2446 bytes test_blossom/blossom/.gitignore | 2 + test_blossom/clean.rs | 3 ++ test_blossom/config.toml | 37 ++++++++++++++ test_blossom/create_auth.sh | 26 ++++++++++ test_blossom/run.rs | 6 +++ test_blossom/test.sh | 72 +++++++++++++++++++++++++++ 9 files changed, 156 insertions(+) create mode 100644 test_blossom/.gitignore create mode 100644 test_blossom/README.md create mode 100644 test_blossom/avatar-placeholder.webp create mode 100644 test_blossom/blossom/.gitignore create mode 100755 test_blossom/clean.rs create mode 100644 test_blossom/config.toml create mode 100755 test_blossom/create_auth.sh create mode 100755 test_blossom/run.rs create mode 100755 test_blossom/test.sh diff --git a/test_blossom/.gitignore b/test_blossom/.gitignore new file mode 100644 index 0000000..20080a8 --- /dev/null +++ b/test_blossom/.gitignore @@ -0,0 +1,3 @@ +event.map +lmdb/ +downloaded diff --git a/test_blossom/README.md b/test_blossom/README.md new file mode 100644 index 0000000..dbdd73d --- /dev/null +++ b/test_blossom/README.md @@ -0,0 +1,7 @@ +# Testing Blossom functionality of Chorus + +In one shell run `run.sh` to run a local chorus. + +Then in another shell run `test.sh` to run tests against that running instance. + +When done, break out of the server and run `clean.sh` diff --git a/test_blossom/avatar-placeholder.webp b/test_blossom/avatar-placeholder.webp new file mode 100644 index 0000000000000000000000000000000000000000..e8f5973c66956186ffbb0ba99e5c49e8dacc4e17 GIT binary patch literal 2446 zcmYk8c{tSj7stPY$dY|uB1V*Lh_VzCVHa@!*b~e}H2N!!kBK+Gb2Gd>xK|s+1TGMla_`r=DtABfS zO$4-tWpR6kqWxuzT#2{-xK7E(tflqMUExyM`B2c`2-7@?Hx>`K1;>?DOS$y#U(-|9 zG9C?Uy#dsQH~KeepL+Mml&~$;i<8Th6bo)d8HMXT&wm(=gy&9bt;Ns* zE;6=*@b+^uR)lM13o1AZDtyr0@g{R>?cigVo%9YO#6u=ogo)GR+^dtmo@GX~c23j) zh}6L$N_~7>$uFYg^Orv5a)Wwy6fwYQn@_Q*75A~y{`}==LXJz!Auiaskh8RhU%#2B zx{eFI9OJvyC*`KKiTQYDUH7A7{SI$3Q};&Qo=us;_KhW*{ByB}4VXc$b;GW$yz*Yy zSBWh5w5ZJ;eG}2kKb&52#qo6S0_>)5RT>=yXA?|H>ZN;-ax8}N>*2hg4sBt0i3y?r zpVRDV2r{8_TByg{@nNiBL{g{MfF|T^?`?5LQ*|n~Q)J-0@?R8B_GpIzJ;4JFR_9HJ zQ0)PJ<$KGX!t@;OQi7^}`$Ewk1EGTUb8CEzhEhpts8p@)@gTOwX=O^6?wifMkV~hR z4}_5t8aKyM+DKJvoVVcbm4@ zCmxG`Ws4=)W68W(KiM=oWtZH|qR@G$Wl`Z3mMdQf55a>FKZCiT_-qunghx{57nVOu z^Fwdt?5i{~Wq3v>FSCsJD)OtK%^fJaw||w%nJ`r8ew65KJx=aGf6LGd zTzE%W;q5qO{wj#K%g~=klm}gSq!^gL>={DwdVVQpON3gl%~xtDHAcQ znVji$Y_^@4_lEFo{kn+?@ii;b*AaEMvkU9F{?g`?&w5MUp1y1EKrjcG27Nb{bh;~E zM6T)1YoqEVK2U#`HFGJ-ny*)sCCs|xLKb-^#i7=Fzll0c4QZdb+8Rh5o?hd1C4~wo zY6&khDOKclccBHCmPG$s`)NaV^2X zNBot~+Ogu#wKg5-{WZCsIhZ(p$1z#HQ0R&FB=fA=oaB<6x0$2zmBC(QCuTHEPD!B zQ|KI3?r&bTjtN&e)cauR&`d0prPen3nxdkNHNP*kxXzc_e+ye559EE(H&><7jo{Js%}Pph(NV^ zJpP01lnsl_mR~y0)uzz&^95VDcZ;!G>J!1wO*fU|8^$F2!F_H<`4Z-Z^&j>t{&CMGXvhq(OHPF!JLk{3LF3xMZ!dE))hW zPC6J#O}#&#)gfDs-^ENxD$gWXk}~ou(&qiJ3}K4e^l8FR6VvtAlMLHy$P2Si2^T3g zc6`k39*AahbTWB#Uec$1bX(6sX*9J#who^(QUH!BvcJ>bO}2ic2yPf^8_Ab^R%F{R zm;2to29jH`p}fzR66;6ruNPC1r}`Oa0zzFBz4?WeoVfFvZPH||Dqxy<(uef z@0-IAfq|DVUd(QS*Lo4^7h^Lmu$7A??B!pM-Isn)d2MRzJ0T2Mv_PU%6(^ck957bb8Sm)3kWG2*;te=NxEu6+O$fat$$wRMA z2sN>fX0z>GVvY3|6WP%0n=%_aO1RM}`b|VjFFzDqH=40^pFYR_(lAPMxctr%UFzyG zBRS!kj6%&4qfBHJLqM2C=<~3n!$lN==tM9{qK}JpMdXM{f9&#jy{l_1fK%=L{A3$| z>)4ME#dojdfD?<&2ez6^Qfv$MNmz%ogvXWrZ+KsnSMgQAh(|%I1q)sV7upm0$O*TE znoI7Wt=xX8rvPw#!jBW;|7360kVsNe4P%ZLx#x9y(Y0f?OmX;oqe8|kNk9(j$h#&| zhjX4kVM+6_6MNalaHp95`o^^WGv)McaJj3q5xBf#mBsLskY@-uZ}pV~E2p%vhPSKm zAS3hHwI=0;;5}=bi6|LI(8SDm%E?~K$zCxtAiE`SJeAh#Cu{ee&kBKAsZ|;XF5uWe zD^SwFg?wXcsJ&E(~JQ{$idfY8)A~S@mvtI4`&C=ME$(qPzgMpyi&nzO9cPaJw zy@6X?7NVPRpXGi!RtI2Nt1F7fkSUyXx}@7Br~3Im(ZQK}5~Fj>wG7Vawi7xOmoJ8G zWY@d^h$jn}^#fQBaf79mnu{o(BAVV6^;;^WRD{N2yEz r1FZv5QHFq?{i8uN0)YFsuMB~JaQ_fbXts(D_Yd(GhW`KbFZ_Q1B@AlJ literal 0 HcmV?d00001 diff --git a/test_blossom/blossom/.gitignore b/test_blossom/blossom/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/test_blossom/blossom/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/test_blossom/clean.rs b/test_blossom/clean.rs new file mode 100755 index 0000000..80a123d --- /dev/null +++ b/test_blossom/clean.rs @@ -0,0 +1,3 @@ +#!/bin/bash + +rm -rf ./event.map ./lmdb/ ./blossom/* ./downloaded diff --git a/test_blossom/config.toml b/test_blossom/config.toml new file mode 100644 index 0000000..c6b2f8f --- /dev/null +++ b/test_blossom/config.toml @@ -0,0 +1,37 @@ +# See contrib/chorus.toml for a documented config file + +data_directory = "." +ip_address = "127.0.0.1" +port = 8089 +hostname = "localhost" +chorus_is_behind_a_proxy = false +use_tls = false +certchain_pem_path = "tls/fullchain.pem" +key_pem_path = "tls/privkey.pem" +name = "Chorus Sample" +description = "A sample run of the Chorus relay" +# icon_url = +open_relay = false +user_hex_keys = [ + "12bb541d03bfc3cab0f4a8e4db28947f60faae6fca4e315eb27f809c6eff9a0b" +] +moderator_hex_keys = [ + "12bb541d03bfc3cab0f4a8e4db28947f60faae6fca4e315eb27f809c6eff9a0b" +] +verify_events = true +allow_scraping = false +allow_scrape_if_limited_to = 100 +allow_scrape_if_max_seconds = 7200 +max_subscriptions = 128 +serve_ephemeral = true +serve_relay_lists = true +server_log_level = "Info" +library_log_level = "Info" +client_log_level = "Warn" +enable_ip_blocking = true +minimum_ban_seconds = 1 +timeout_seconds = 60 +max_connections_per_ip = 5 +throttling_bytes_per_second = 131072 +throttling_burst = 4194304 +blossom_directory = "./blossom" \ No newline at end of file diff --git a/test_blossom/create_auth.sh b/test_blossom/create_auth.sh new file mode 100755 index 0000000..a536e1b --- /dev/null +++ b/test_blossom/create_auth.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +PUBKEY=12bb541d03bfc3cab0f4a8e4db28947f60faae6fca4e315eb27f809c6eff9a0b +PRIVKEY=b4a98d96270b6cd30c80e4fd594461d2b22d8dbcfbcd1f7b11bf0ef2b028a56b +AUTH_EXPIRATION=1900000000 + +VERB=$1 +HASH=$2 + +if [ x$VERB = x ] ; then + echo "USAGE: create_auth.sh VERB HASH" + exit 1 +fi + +if [ x$HASH = x ] ; then + echo "USAGE: create_auth.sh VERB HASH" + exit 1 +fi + +PRE_EVENT='{"pubkey": "'$PUBKEY'", "kind": 24242, "created_at": 0, "tags": [["expiration","'$AUTH_EXPIRATION'"], ["t","'$VERB'"], ["x","'$HASH'"]], "content":""}' + +EVENT=$(echo "$PRE_EVENT" | nak event --sec $PRIVKEY) + +EVENT_BASE64=$(echo $EVENT | base64 -w 0) + +echo "Authorization: Nostr $EVENT_BASE64" diff --git a/test_blossom/run.rs b/test_blossom/run.rs new file mode 100755 index 0000000..ecfbf4b --- /dev/null +++ b/test_blossom/run.rs @@ -0,0 +1,6 @@ +#!/bin/bash + +pushd .. +cargo build --release || exit 1 +popd +../target/release/chorus ./config.toml diff --git a/test_blossom/test.sh b/test_blossom/test.sh new file mode 100755 index 0000000..7ef675b --- /dev/null +++ b/test_blossom/test.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +if ! command -v curl 2>&1 >/dev/null +then + echo "curl command is required." + exit 1 +fi + +if ! command -v jq 2>&1 >/dev/null +then + echo "jq command is required." + exit 1 +fi + +if ! command -v nak 2>&1 >/dev/null +then + echo "nak command is required. https://github.com/fiatjaf/nak" + exit 1 +fi + +# UPLOAD TEST ------------ + +FILE="./avatar-placeholder.webp" +HASH=$(sha256sum $FILE | awk '{print $1}') + +# Generate nostr auth +AUTH=$(./create_auth.sh upload $HASH) + +# Upload +DESCRIPTOR=$(curl -s --data-binary @"$FILE" -X PUT --header "$AUTH" http://127.0.0.1:8089/upload) +if [ $? -ne 0 ] ; then + echo "FAILED: Curl (uploading) exited with a non-zero status" + exit 1 +fi +echo "PASS: FILE UPLOADED" + +# Extract the sha256 and compare it +DHASH=$(echo "$DESCRIPTOR" | jq -r .sha256) +if [ $? -ne 0 ] ; then + echo "FAILED: jq failed extracting sha256 from descriptor" + exit 1 +fi +if [ "$HASH" != "$DHASH" ] ; then + echo "returned descriptor 'sha256' does not match the hash" +fi +echo "PASS: DESCRIPTOR HASH MATCHES" + +# Extract the URL for download +URL=$(echo "$DESCRIPTOR" | jq -r .url) +if [ $? -ne 0 ] ; then + echo "FAILED: jq failed extracting url from descriptor" + exit 1 +fi + +# DOWNLOAD TEST ----------- + +curl -s "$URL" > downloaded +if [ $? -ne 0 ] ; then + echo "FAILED: Curl (downloading) exited with a non-zero status" + exit 1 +fi +echo "PASS: FILE DOWNLOADED" + +# Compare the files +if cmp -s "$FILE" downloaded; then + echo "PASS: THE DOWNLOADED FILE MATCHES THE UPLOADED FILE" +else + echo "FAIL: THE DOWNLOADED FILE DOES NOT MATCH THE UPLOADED FILE" +fi + +echo "end." +exit 0