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 log::debug; use peach_lib::config_manager; use peach_lib::sbot::SbotConfig; use peach_lib::sbot::init_sbot; use peach_lib::ssb_messages::SsbMessageKVT; use rouille::input::post::BufferedFile; use temporary::Directory; use peach_lib::serde_json::json; use peach_lib::tilde_client::{TildeClient, TildeError}; use crate::{error::PeachWebError, utils::sbot}; // SBOT HELPER FUNCTIONS /// Executes a systemctl command for the solar-sbot.service process. pub fn systemctl_sbot_cmd(cmd: &str) -> Result { let output = Command::new("sudo") .arg("systemctl") .arg(cmd) .arg(config_manager::get_config_value("TILDE_SBOT_SERVICE")?) .output()?; Ok(output) } /// Executes a systemctl 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 solar-sbot.service"); match systemctl_sbot_cmd("stop") { // if stop was successful, try to start the process Ok(_) => match systemctl_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_client() -> Result { debug!("Initialising an sbot client with configuration parameters"); let sbot_client = init_sbot().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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client().await?; let sequence_num = sbot_client.latest_sequence_number().await?; Ok(sequence_num) // retrieve the local id // let id = sbot_client.whoami().await?; // let history_stream = sbot_client.feed(&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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client().await?; debug!("Generating Scuttlebutt invite code"); let mut invite_code = sbot_client.create_invite(uses as i32).await?; 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> { task::block_on(async { let sbot_client = init_sbot_client().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; // query follow state profile.following = Some(sbot_client.is_following(&local_id, &peer_id).await?); // TODO: implement this check in solar_client so that this can be a real value profile.blocking = Some(false); 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 = get_peer_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); // TODO: blobs support // // 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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client().await?; // // 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(PeachWebError::Tilde(TildeError {message: (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(PeachWebError::Tilde(TildeError {message: (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(PeachWebError::Tilde(TildeError {message: (format!("Failed to update image: {}", e))})); } else { image_updated = true } } Err(e) => { return Err(PeachWebError::Tilde(TildeError {message: (format!("Failed to add image to blob store: {}", 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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client() .await?; debug!("Following a Scuttlebutt peer"); Err(PeachWebError::NotYetImplemented) }) } /// Unfollow a peer. pub fn unfollow_peer(public_key: &str) -> Result { // retrieve latest solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client() .await?; Err(PeachWebError::NotYetImplemented) // 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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client() .await .map_err(|e| e.to_string())?; debug!("Blocking a Scuttlebutt peer"); match sbot_client.create_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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client() .await?; debug!("Unblocking a Scuttlebutt peer"); Err(PeachWebError::NotYetImplemented) // 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> { // populate this vec to return let mut to_return: Vec> = Vec::new(); task::block_on(async { let mut sbot_client = init_sbot_client().await?; let self_id = sbot_client.whoami().await?; let blocks = sbot_client.get_blocks(&self_id).await?; if !blocks.is_empty() { for peer in blocks.iter() { // trim whitespace (including newline characters) and // remove the inverted-commas around the id // TODO: is this necessary? let key = peer.trim().replace('"', ""); let peer_info = get_peer_info(&key).await?; // push profile info to peer_list vec to_return.push(peer_info) } } // return the list of peers Ok(to_return) }) } pub async fn get_peer_info(key: &str) -> Result, Box> { let mut sbot_client = init_sbot_client().await?; // key,value dict of info about this peer let tilde_profile_info = sbot_client.get_profile_info(key).await.map_err(|err| { println!("error getting profile info: {}", err); err } )?; let mut peer_info = HashMap::new(); tilde_profile_info.get("name").and_then(|val| val.as_str()).map(|val| { peer_info.insert("name".to_string(), val.to_string()); }); tilde_profile_info.get("description").and_then(|val| val.as_str()).map(|val| { peer_info.insert("description".to_string(), val.to_string()); }); // insert the public key of the peer into the info hashmap peer_info.insert("id".to_string(), key.to_string()); // TODO: display profile photo blob // // 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()), // }; // } // } Ok(peer_info) } /// Retrieve a list of peers followed by the local public key. pub fn get_follows_list() -> Result>, Box> { // populate this vec to return let mut to_return: Vec> = Vec::new(); task::block_on(async { let mut sbot_client = init_sbot_client().await?; let self_id = sbot_client.whoami().await?; let follows = sbot_client.get_follows(&self_id).await?; if !follows.is_empty() { for peer in follows.iter() { // trim whitespace (including newline characters) and // remove the inverted-commas around the id // TODO: is this necessary? let key = peer.trim().replace('"', ""); let peer_info = get_peer_info(&key).await?; // push profile info to peer_list vec to_return.push(peer_info) } } // return the list of peers Ok(to_return) }) } /// Retrieve a list of peers friended by the local public key. pub fn get_friends_list() -> Result>, Box> { // populate this vec to return let mut to_return: Vec> = Vec::new(); task::block_on(async { let mut sbot_client = init_sbot_client().await?; let self_id = sbot_client.whoami().await?; let friends = sbot_client.get_friends(&self_id).await?; if !friends.is_empty() { for peer in friends.iter() { // trim whitespace (including newline characters) and // remove the inverted-commas around the id // TODO: is this necessary? let key = peer.trim().replace('"', ""); let peer_info = get_peer_info(&key).await?; // push profile info to peer_list vec to_return.push(peer_info) } } // return the list of peers Ok(to_return) }) } /// Retrieve the local public key (id). pub fn get_local_id() -> Result> { // retrieve latest solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client().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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client() .await .map_err(|e| e.to_string())?; debug!("Publishing a new Scuttlebutt public post"); let post = json!({ "type": "post", "text": &text, }); match sbot_client.publish(post).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 solar-sbot configuration parameters let sbot_config = SbotConfig::read().ok(); task::block_on(async { let mut sbot_client = init_sbot_client() .await?; for recipient in &recipients { sbot_client.private_message(recipient, &text).await?; } Ok("Published private message".to_string()) }) } // FILEPATH FUNCTIONS /// Return the path of the tilde-sbot directory. pub fn get_tilde_ssb_path() -> Result { let tilde_ssb_path = match SbotConfig::read() { Ok(conf) => conf.database_directory, // 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 .ssb-tilde subdirectory home_path.push(".ssb-tilde"); // convert the PathBuf to a String home_path .into_os_string() .into_string() .map_err(|_| PeachWebError::OsString)? } }; Ok(tilde_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_tilde_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)?; // create blob from file let mut sbot_client = init_sbot_client().await?; let blob_path = temp_path.to_str().ok_or("Error storing blob to disk").map_err(|e| PeachWebError::Tilde(TildeError { message: format!("Error storing blob to disk: {}", e), }))?; let blob_id = sbot_client.store_blob(blob_path).await?; Ok(blob_id) }