diff --git a/Cargo.lock b/Cargo.lock index b460660..82d8ca9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.1.0" @@ -282,6 +291,15 @@ dependencies = [ "cache-padded", ] +[[package]] +name = "cpufeatures" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.7" @@ -292,6 +310,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crypto-common" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.1.21" @@ -302,6 +330,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "2.0.2" @@ -448,6 +486,16 @@ version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2" +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "get_if_addrs" version = "0.5.3" @@ -507,6 +555,7 @@ dependencies = [ "kuska-ssb", "serde", "serde_json", + "sha2", ] [[package]] @@ -814,6 +863,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +dependencies = [ + "cfg-if 1.0.0", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook" version = "0.3.13" @@ -880,6 +940,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + [[package]] name = "unicode-xid" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 6b62620..7f385c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ kuska-sodiumoxide = "0.2.5-0" kuska-ssb = "0.4.0" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10.2" diff --git a/src/api/publish.rs b/src/api/publish.rs index 33c8361..5c64706 100644 --- a/src/api/publish.rs +++ b/src/api/publish.rs @@ -4,9 +4,12 @@ //! //! - [`Sbot::publish`] //! - [`Sbot::publish_description`] +//! - [`Sbot::publish_image`] //! - [`Sbot::publish_name`] //! - [`Sbot::publish_post`] +use kuska_ssb::api::dto::content::Image; + use crate::{error::GolgiError, messages::SsbMessageContent, sbot::Sbot, utils}; impl Sbot { @@ -116,6 +119,45 @@ impl Sbot { self.publish(msg).await } + /// Publish an image for the local identity. + /// + /// Requires the image to have been added to the local blobstore. The + /// ID of the blob (sigil-link in the form `&...=.sha256`) must be provided. + /// + /// Convenient wrapper around the `publish` method which constructs and + /// publishes an `about` type name message appropriately from a string. + /// + /// # Example + /// + /// ```rust + /// use golgi::{Sbot, GolgiError}; + /// + /// async fn publish_an_image() -> Result<(), GolgiError> { + /// let mut sbot_client = Sbot::init(None, None).await?; + /// + /// let blob_id = "&JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8=.sha256"; + /// + /// let msg_ref = sbot_client.publish_image(blob_id).await?; + /// + /// println!("msg reference: {}", msg_ref); + /// + /// Ok(()) + /// } + /// ``` + pub async fn publish_image(&mut self, blob_id: &str) -> Result { + let msg = SsbMessageContent::About { + about: self.id.to_string(), + name: None, + title: None, + branch: None, + image: Some(Image::OnlyLink(blob_id.to_string())), + description: None, + location: None, + start_datetime: None, + }; + self.publish(msg).await + } + /// Publish a name for the local identity. /// /// Convenient wrapper around the `publish` method which constructs and diff --git a/src/blobs.rs b/src/blobs.rs new file mode 100644 index 0000000..2199970 --- /dev/null +++ b/src/blobs.rs @@ -0,0 +1,145 @@ +//! Blob utilities which do not require RPC calls. +//! +//! Offers the following functions: +//! +//! - [`get_blob_path()`] +//! - [`hash_blob()`] + +use base64; +use sha2::{Digest, Sha256}; + +use crate::error::GolgiError; + +/// Lookup the filepath for a blob. +/// +/// # Example +/// +/// ```rust +/// use golgi::{blobs, GolgiError}; +/// +/// fn lookup_blob_path() -> Result<(), GolgiError> { +/// let blob_ref = "&JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8=.sha256"; +/// +/// let blob_path = blobs::get_blob_path(blob_ref)?; +/// +/// println!("{}", blob_path); +/// +/// // 26/524773dc9e1b5129640f5f20f18bcd42831f415e477f408b9edeba1293d46f +/// +/// Ok(()) +/// } +/// ``` +pub fn get_blob_path(blob_id: &str) -> Result { + // ensure the id starts with the correct sigil link + if !blob_id.starts_with('&') { + return Err(GolgiError::SigilLink(format!( + "incorrect first character, expected '&' sigil: {}", + blob_id + ))); + } + + // find the dot index denoting the start of the algorithm definition tag + let dot_index = blob_id + .rfind('.') + .ok_or_else(|| GolgiError::SigilLink(format!("no dot index was found: {}", blob_id)))?; + + // obtain the base64 portion (substring) of the blob id + let base64_str = &blob_id[1..dot_index]; + + // decode blob substring from base64 (to bytes) + let blob_bytes = base64::decode_config(base64_str, base64::STANDARD)?; + + // represent the blob bytes as hex, removing all unnecessary characters + let blob_hex = format!("{:02x?}", blob_bytes) + .replace('[', "") + .replace(']', "") + .replace(',', "") + .replace(' ', ""); + + // split the hex representation of the decoded base64 + // this is how paths are formatted for the blobstore + // e.g. 26/524773dc9e1b5129640f5f20f18bcd42831f415e477f408b9edeba1293d46f + // full path would be: `/home/user/.ssb-go/blobs/sha256/26/524773dc...` + let blob_path = format!("{}/{}", &blob_hex[..2], &blob_hex[2..]); + + Ok(blob_path) +} + +/// Hash the given blob byte slice and return the hex representation and +/// blob ID (sigil-link). +/// +/// This function can be used when adding a blob to the local blobstore. +pub fn hash_blob(buffer: &[u8]) -> Result<(String, String), GolgiError> { + // hash the bytes + let hash = Sha256::digest(buffer); + + // generate a hex representation of the hash + let hex_hash = format!("{:02x?}", hash) + .replace('[', "") + .replace(']', "") + .replace(',', "") + .replace(' ', ""); + + // encode the hash + let b64_hash = base64::encode(&hash[..]); + + // format the base64 encoding as a blob sigil-link (blob id) + let blob_id = format!("&{}.sha256", b64_hash); + + Ok((hex_hash, blob_id)) +} + +#[cfg(test)] +mod tests { + use crate::blobs; + + /* HAPPY PATH TESTS */ + + #[test] + fn blob_path() { + let blob_ref = "&JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8=.sha256"; + let blob_path = blobs::get_blob_path(blob_ref); + assert!(blob_path.is_ok()); + assert_eq!( + blob_path.unwrap(), + "26/524773dc9e1b5129640f5f20f18bcd42831f415e477f408b9edeba1293d46f" + ); + } + + #[test] + fn hashed_blob() { + // pretend this represents a file which has been written to a buffer + let buffer = vec![7; 128]; + let hash_result = blobs::hash_blob(&buffer); + assert!(hash_result.is_ok()); + let (hex_hash, blob_id) = hash_result.unwrap(); + assert_eq!( + hex_hash, + "4c1398e54d53e925cff04da532f4bbaf15f75b5981fc76c2072dfdc6491a9fb1" + ); + assert_eq!( + blob_id, + "&TBOY5U1T6SXP8E2lMvS7rxX3W1mB/HbCBy39xkkan7E=.sha256" + ); + } + + /* SAD PATH TESTS */ + + #[test] + fn blob_id_without_sigil() { + let blob_ref = "JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8=.sha256"; + match blobs::get_blob_path(blob_ref) { + Err(e) => assert_eq!(e.to_string(), "SSB blob ID error: incorrect first character, expected '&' sigil: JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8=.sha256"), + _ => panic!(), + } + } + + #[test] + fn blob_id_without_algo() { + let blob_ref = "&JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8="; + match blobs::get_blob_path(blob_ref) { + Err(e) => assert_eq!(e.to_string(), "SSB blob ID error: no dot index was found: &JlJHc9yeG1EpZA9fIPGLzUKDH0FeR39Ai57euhKT1G8="), + _ => panic!(), + } + } +} diff --git a/src/error.rs b/src/error.rs index 010c834..d5efe84 100644 --- a/src/error.rs +++ b/src/error.rs @@ -34,9 +34,11 @@ pub enum GolgiError { Rpc(RpcError), /// Go-sbot error. Sbot(String), + /// SSB sigil-link error. + SigilLink(String), /// JSON serialization or deserialization error. SerdeJson(JsonError), - /// Error decoding typed ssb message from content. + /// Error decoding typed SSB message from content. ContentType(String), /// Error decoding UTF8 string from bytes Utf8Parse { @@ -55,6 +57,7 @@ impl std::error::Error for GolgiError { GolgiError::Feed(ref err) => Some(err), GolgiError::Rpc(ref err) => Some(err), GolgiError::Sbot(_) => None, + GolgiError::SigilLink(_) => None, GolgiError::SerdeJson(ref err) => Some(err), GolgiError::ContentType(_) => None, GolgiError::Utf8Parse { ref source } => Some(source), @@ -75,11 +78,12 @@ impl std::fmt::Display for GolgiError { // then have the core display msg be: "SSB RPC error: {}", context GolgiError::Rpc(ref err) => write!(f, "SSB RPC failure: {}", err), GolgiError::Sbot(ref err) => write!(f, "Sbot returned an error response: {}", err), + GolgiError::SigilLink(ref context) => write!(f, "SSB blob ID error: {}", context), GolgiError::SerdeJson(_) => write!(f, "Failed to serialize JSON slice"), //GolgiError::WhoAmI(ref err) => write!(f, "{}", err), GolgiError::ContentType(ref err) => write!( f, - "Failed to decode typed message from ssb message content: {}", + "Failed to decode typed message from SSB message content: {}", err ), GolgiError::Utf8Parse { source } => { diff --git a/src/lib.rs b/src/lib.rs index 1869e24..4b36222 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,7 @@ //! ``` pub mod api; +pub mod blobs; pub mod error; pub mod messages; pub mod sbot;