use std::{ collections::HashMap, error::Error, fs, fs::File, io::prelude::*, path::Path, process::{Command, Output}, }; use async_std::task; use dirs; use futures::stream::TryStreamExt; use golgi::{ api::friends::RelationshipQuery, blobs, messages::SsbMessageKVT, sbot::Keystore, Sbot, }; use log::debug; use peach_lib::config_manager; use peach_lib::sbot::SbotConfig; use rouille::input::post::BufferedFile; use temporary::Directory; use crate::{error::PeachWebError, utils::sbot}; // SBOT HELPER FUNCTIONS /// On non-docker based deployments (peachcloud, yunohost), we use systemctl /// On docker-based deployments, we use supervisord /// This utility function calls the correct system calls based on these parameters. pub fn system_sbot_cmd(cmd: &str) -> Result { let system_manager = config_manager::get_config_value("SYSTEM_MANAGER")?; match system_manager.as_str() { "systemd" => { let output = Command::new("sudo") .arg("systemctl") .arg(cmd) .arg(config_manager::get_config_value("GO_SBOT_SERVICE")?) .output()?; Ok(output) } "supervisord" => { match cmd { "enable" => { // TODO: implement this let output = Command::new("echo") .arg("implement this (enable)") .output()?; Ok(output) } "disable" => { let output = Command::new("echo") .arg("implement this (disable)") .output()?; Ok(output) } _ => { let output = Command::new("supervisorctl") .arg(cmd) .arg(config_manager::get_config_value("GO_SBOT_SERVICE")?) .output()?; Ok(output) } } } _ => Err(PeachWebError::System(format!( "Invalid configuration for SYSTEM_MANAGER: {:?}", system_manager ))), } } /// Executes a system stop command followed by start command. /// Returns a redirect with a flash message stating the output of the restart attempt. pub fn restart_sbot_process() -> (String, String) { debug!("Restarting go-sbot.service"); match system_sbot_cmd("stop") { // if stop was successful, try to start the process Ok(_) => match system_sbot_cmd("start") { Ok(_) => ( "success".to_string(), "Updated configuration and restarted the sbot process".to_string(), ), Err(err) => ( "error".to_string(), format!( "Updated configuration but failed to start the sbot process: {}", err ), ), }, Err(err) => ( "error".to_string(), format!( "Updated configuration but failed to stop the sbot process: {}", err ), ), } } /// Initialise an sbot client with the given configuration parameters. pub async fn init_sbot_with_config( sbot_config: &Option, ) -> Result { debug!("Initialising an sbot client with configuration parameters"); // initialise sbot connection with ip:port and shscap from config file let key_path = format!( "{}/secret", config_manager::get_config_value("GO_SBOT_DATADIR")? ); let sbot_client = match sbot_config { // TODO: panics if we pass `Some(conf.shscap)` as second arg Some(conf) => { let ip_port = conf.lis.clone(); Sbot::init(Keystore::CustomGoSbot(key_path), Some(ip_port), None).await? } None => Sbot::init(Keystore::CustomGoSbot(key_path), None, None).await?, }; Ok(sbot_client) } // SCUTTLEBUTT FUNCTIONS /// Ensure that the given public key is a valid ed25519 key. /// /// Return an error string if the key is invalid. pub fn validate_public_key(public_key: &str) -> Result<(), String> { // ensure the id starts with the correct sigil link if !public_key.starts_with('@') { return Err("Invalid key: expected '@' sigil as first character".to_string()); } // find the dot index denoting the start of the algorithm definition tag let dot_index = match public_key.rfind('.') { Some(index) => index, None => return Err("Invalid key: no dot index was found".to_string()), }; // check hashing algorithm (must end with ".ed25519") if !&public_key.ends_with(".ed25519") { return Err("Invalid key: hashing algorithm must be ed25519".to_string()); } // obtain the base64 portion (substring) of the public key let base64_str = &public_key[1..dot_index]; // length of a base64 encoded ed25519 public key if base64_str.len() != 44 { return Err("Invalid key: base64 data length is incorrect".to_string()); } Ok(()) } /// Calculate the latest sequence number for the local profile. /// /// Retrieves a list of all messages authored by the local public key, /// reverses the list and reads the sequence number of the most recently /// authored message. This gives us the size of the database in terms of /// the total number of locally-authored messages. pub fn latest_sequence_number() -> Result> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; // retrieve the local id let id = sbot_client.whoami().await?; let history_stream = sbot_client.create_history_stream(id).await?; let mut msgs: Vec = history_stream.try_collect().await?; // there will be zero messages when the sbot is run for the first time if msgs.is_empty() { Ok(0) } else { // reverse the list of messages so we can easily reference the latest one msgs.reverse(); // return the sequence number of the latest msg Ok(msgs[0].value.sequence) } }) } pub fn create_invite(uses: u16) -> Result> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; debug!("Generating Scuttlebutt invite code"); let mut invite_code = sbot_client.invite_create(uses).await?; // insert domain into invite if one is configured let domain = config_manager::get_config_value("EXTERNAL_DOMAIN")?; if !domain.is_empty() { invite_code = domain + &invite_code[4..]; } Ok(invite_code) }) } #[derive(Debug)] pub struct Profile { // is this the local profile or the profile of a peer? pub is_local_profile: bool, // an ssb_id which may or may not be the local public key pub id: Option, pub name: Option, pub description: Option, pub image: Option, // the path to the blob defined in the `image` field (aka the profile picture) pub blob_path: Option, // whether or not the blob exists in the blobstore (ie. is saved on disk) pub blob_exists: bool, // relationship state (if the profile being viewed is not for the local public key) pub following: Option, pub blocking: Option, } impl Profile { pub fn default() -> Self { Profile { is_local_profile: true, id: None, name: None, description: None, image: None, blob_path: None, blob_exists: false, following: None, blocking: None, } } } /// Retrieve the profile info for the given public key. pub fn get_profile_info(ssb_id: Option) -> Result> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; let local_id = sbot_client.whoami().await?; let mut profile = Profile::default(); // if an ssb_id has been provided, we assume that the profile info // being retrieved is for a peer (ie. not for our local profile) let id = if let Some(peer_id) = ssb_id { // we are not dealing with the local profile profile.is_local_profile = false; // determine relationship between peer and local id let follow_query = RelationshipQuery { source: local_id.clone(), dest: peer_id.clone(), }; // query follow state profile.following = match sbot_client.friends_is_following(follow_query).await { Ok(following) if following == "true" => Some(true), Ok(following) if following == "false" => Some(false), _ => None, }; // TODO: i don't like that we have to instantiate the same query object // twice. see if we can streamline this in golgi let block_query = RelationshipQuery { source: local_id.clone(), dest: peer_id.clone(), }; // query block state profile.blocking = match sbot_client.friends_is_blocking(block_query).await { Ok(blocking) if blocking == "true" => Some(true), Ok(blocking) if blocking == "false" => Some(false), _ => None, }; peer_id } else { // if an ssb_id has not been provided, retrieve the local id using whoami profile.is_local_profile = true; local_id }; // retrieve the profile info for the given id let info = sbot_client.get_profile_info(&id).await?; // set each profile field accordingly for (key, val) in info { match key.as_str() { "name" => profile.name = Some(val), "description" => profile.description = Some(val), "image" => profile.image = Some(val), _ => (), } } // assign the ssb public key // (could be for the local profile or a peer) profile.id = Some(id); // determine the path to the blob defined by the value of `profile.image` if let Some(ref blob_id) = profile.image { profile.blob_path = match blobs::get_blob_path(blob_id) { Ok(path) => { // if we get the path, check if the blob is in the blobstore. // this allows us to default to a placeholder image in the template if let Ok(exists) = blob_is_stored_locally(&path).await { profile.blob_exists = exists }; Some(path) } Err(_) => None, } } Ok(profile) }) } /// Update the profile info for the local public key. /// /// Profile info includes name, description and image. pub fn update_profile_info( current_name: String, current_description: String, new_name: Option, new_description: Option, image: Option, ) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; // track whether the name, description or image have been updated let mut name_updated: bool = false; let mut description_updated: bool = false; let mut image_updated: bool = false; // check if a new_name value has been submitted in the form if let Some(name) = new_name { // only update the name if it has changed if name != current_name { debug!("Publishing a new Scuttlebutt profile name"); if let Err(e) = sbot_client.publish_name(&name).await { return Err(format!("Failed to update name: {}", e)); } else { name_updated = true } } } if let Some(description) = new_description { // only update the description if it has changed if description != current_description { debug!("Publishing a new Scuttlebutt profile description"); if let Err(e) = sbot_client.publish_description(&description).await { return Err(format!("Failed to update description: {}", e)); } else { description_updated = true } } } // only update the image if a file was uploaded if let Some(img) = image { // only write the blob if it has a filename and data > 0 bytes if img.filename.is_some() && !img.data.is_empty() { match write_blob_to_store(img).await { Ok(blob_id) => { // if the file was successfully added to the blobstore, // publish an about image message with the blob id if let Err(e) = sbot_client.publish_image(&blob_id).await { return Err(format!("Failed to update image: {}", e)); } else { image_updated = true } } Err(e) => return Err(format!("Failed to add image to blobstore: {}", e)), } } else { image_updated = false } } if name_updated || description_updated || image_updated { Ok("Profile updated".to_string()) } else { // no updates were made but no errors were encountered either Ok("Profile info unchanged".to_string()) } }) } /// Follow a peer. pub fn follow_peer(public_key: &str) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; debug!("Following a Scuttlebutt peer"); match sbot_client.follow(public_key).await { Ok(_) => Ok("Followed peer".to_string()), Err(e) => Err(format!("Failed to follow peer: {}", e)), } }) } /// Unfollow a peer. pub fn unfollow_peer(public_key: &str) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; debug!("Unfollowing a Scuttlebutt peer"); match sbot_client.unfollow(public_key).await { Ok(_) => Ok("Unfollowed peer".to_string()), Err(e) => Err(format!("Failed to unfollow peer: {}", e)), } }) } /// Block a peer. pub fn block_peer(public_key: &str) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; debug!("Blocking a Scuttlebutt peer"); match sbot_client.block(public_key).await { Ok(_) => Ok("Blocked peer".to_string()), Err(e) => Err(format!("Failed to block peer: {}", e)), } }) } /// Unblock a peer. pub fn unblock_peer(public_key: &str) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; debug!("Unblocking a Scuttlebutt peer"); match sbot_client.unblock(public_key).await { Ok(_) => Ok("Unblocked peer".to_string()), Err(e) => Err(format!("Failed to unblock peer: {}", e)), } }) } /// Retrieve a list of peers blocked by the local public key. pub fn get_blocks_list() -> Result>, Box> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; let blocks = sbot_client.get_blocks().await?; // we'll use this to store the profile info for each peer whom we block let mut peer_list = Vec::new(); if !blocks.is_empty() { for peer in blocks.iter() { // trim whitespace (including newline characters) and // remove the inverted-commas around the id let key = peer.trim().replace('"', ""); // retrieve the profile info for the given peer let mut peer_info = sbot_client.get_profile_info(&key).await?; // insert the public key of the peer into the info hashmap peer_info.insert("id".to_string(), key.to_string()); // we do not even attempt to find the blob for a blocked peer, // since it may be vulgar to cause distress to the local peer. peer_info.insert("blob_exists".to_string(), "false".to_string()); // push profile info to peer_list vec peer_list.push(peer_info) } } // return the list of blocked peers Ok(peer_list) }) } /// Retrieve a list of peers followed by the local public key. pub fn get_follows_list() -> Result>, Box> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; let follows = sbot_client.get_follows().await?; // we'll use this to store the profile info for each peer who follows us let mut peer_list = Vec::new(); if !follows.is_empty() { for peer in follows.iter() { // trim whitespace (including newline characters) and // remove the inverted-commas around the id let key = peer.trim().replace('"', ""); // retrieve the profile info for the given peer let mut peer_info = sbot_client.get_profile_info(&key).await?; // insert the public key of the peer into the info hashmap peer_info.insert("id".to_string(), key.to_string()); // retrieve the profile image blob id for the given peer if let Some(blob_id) = peer_info.get("image") { // look-up the path for the image blob if let Ok(blob_path) = blobs::get_blob_path(blob_id) { // insert the image blob path of the peer into the info hashmap peer_info.insert("blob_path".to_string(), blob_path.to_string()); // check if the blob is in the blobstore // set a flag in the info hashmap match blob_is_stored_locally(&blob_path).await { Ok(exists) if exists => { peer_info.insert("blob_exists".to_string(), "true".to_string()) } _ => peer_info.insert("blob_exists".to_string(), "false".to_string()), }; } } // push profile info to peer_list vec peer_list.push(peer_info) } } // return the list of peers Ok(peer_list) }) } /// Retrieve a list of peers friended by the local public key. pub fn get_friends_list() -> Result>, Box> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; let local_id = sbot_client.whoami().await?; let follows = sbot_client.get_follows().await?; // we'll use this to store the profile info for each friend let mut peer_list = Vec::new(); if !follows.is_empty() { for peer in follows.iter() { // trim whitespace (including newline characters) and // remove the inverted-commas around the id let peer_id = peer.trim().replace('"', ""); // retrieve the profile info for the given peer let mut peer_info = sbot_client.get_profile_info(&peer_id).await?; // insert the public key of the peer into the info hashmap peer_info.insert("id".to_string(), peer_id.to_string()); // retrieve the profile image blob id for the given peer if let Some(blob_id) = peer_info.get("image") { // look-up the path for the image blob if let Ok(blob_path) = blobs::get_blob_path(blob_id) { // insert the image blob path of the peer into the info hashmap peer_info.insert("blob_path".to_string(), blob_path.to_string()); // check if the blob is in the blobstore // set a flag in the info hashmap match sbot::blob_is_stored_locally(&blob_path).await { Ok(exists) if exists => { peer_info.insert("blob_exists".to_string(), "true".to_string()) } _ => peer_info.insert("blob_exists".to_string(), "false".to_string()), }; } } // check if the peer follows us (making us friends) let follow_query = RelationshipQuery { source: peer_id.to_string(), dest: local_id.clone(), }; // query follow state match sbot_client.friends_is_following(follow_query).await { Ok(following) if following == "true" => { // only push profile info to peer_list vec if they follow us peer_list.push(peer_info) } _ => (), }; } } // return the list of peers Ok(peer_list) }) } /// Retrieve the local public key (id). pub fn get_local_id() -> Result> { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config).await?; let local_id = sbot_client.whoami().await?; Ok(local_id) }) } /// Publish a public post. pub fn publish_public_post(text: String) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; debug!("Publishing a new Scuttlebutt public post"); match sbot_client.publish_post(&text).await { Ok(_) => Ok("Published post".to_string()), Err(e) => Err(format!("Failed to publish post: {}", e)), } }) } /// Publish a private message. pub fn publish_private_msg(text: String, recipients: Vec) -> Result { // retrieve latest go-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_with_config(&sbot_config) .await .map_err(|e| e.to_string())?; debug!("Publishing a new Scuttlebutt private message"); match sbot_client .publish_private(text.to_string(), recipients) .await { Ok(_) => Ok("Published private message".to_string()), Err(e) => Err(format!("Failed to publish private message: {}", e)), } }) } // FILEPATH FUNCTIONS /// Return the path of the ssb-go directory. pub fn get_go_ssb_path() -> Result { let go_ssb_path = match SbotConfig::read() { Ok(conf) => conf.repo, // return the default path if unable to read `config.toml` Err(_) => { // determine the home directory let mut home_path = dirs::home_dir().ok_or(PeachWebError::HomeDir)?; // add the go-ssb subdirectory home_path.push(".ssb-go"); // convert the PathBuf to a String home_path .into_os_string() .into_string() .map_err(|_| PeachWebError::OsString)? } }; Ok(go_ssb_path) } /// Check whether a blob is in the blobstore. pub async fn blob_is_stored_locally(blob_path: &str) -> Result { let go_ssb_path = get_go_ssb_path()?; let complete_path = format!("{}/blobs/sha256/{}", go_ssb_path, blob_path); let blob_exists_locally = Path::new(&complete_path).exists(); Ok(blob_exists_locally) } // take the path to a file, add it to the blobstore and return the blob id pub async fn write_blob_to_store(image: BufferedFile) -> Result { // we performed a `image.filename.is_some()` check before calling `write_blob_to_store` // so it should be safe to do a simple unwrap here let filename = image .filename .expect("retrieving filename from uploaded file"); // create temporary directory and path let temp_dir = Directory::new("blob")?; let temp_path = temp_dir.join(filename); // write file to temporary path fs::write(&temp_path, &image.data)?; // open the file and read it into a buffer let mut file = File::open(&temp_path)?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer)?; // hash the bytes representing the file let (hex_hash, blob_id) = blobs::hash_blob(&buffer)?; // define the blobstore path and blob filename let (blob_dir, blob_filename) = hex_hash.split_at(2); let go_ssb_path = get_go_ssb_path()?; let blobstore_sub_dir = format!("{}/blobs/sha256/{}", go_ssb_path, blob_dir); // create the blobstore sub-directory fs::create_dir_all(&blobstore_sub_dir)?; // copy the file to the blobstore let blob_path = format!("{}/{}", blobstore_sub_dir, blob_filename); fs::copy(temp_path, blob_path)?; Ok(blob_id) }