diff --git a/peach-web/src/router.rs b/peach-web/src/router.rs index 279225f..9284e1b 100644 --- a/peach-web/src/router.rs +++ b/peach-web/src/router.rs @@ -106,10 +106,28 @@ pub fn mount_peachpub_routes(request: &Request) -> Response { Response::html(routes::scuttlebutt::friends::build_template()) }, + (GET) (/scuttlebutt/invites) => { + Response::html(routes::scuttlebutt::invites::build_template(request)) + .reset_flash() + }, + + (POST) (/scuttlebutt/invites) => { + routes::scuttlebutt::invites::handle_form(request) + }, + (GET) (/scuttlebutt/peers) => { Response::html(routes::scuttlebutt::peers::build_template()) }, + (GET) (/scuttlebutt/search) => { + Response::html(routes::scuttlebutt::search::build_template(request)) + .reset_flash() + }, + + (POST) (/scuttlebutt/search) => { + routes::scuttlebutt::search::handle_form(request) + }, + (GET) (/status/scuttlebutt) => { Response::html(routes::status::scuttlebutt::build_template()) }, diff --git a/peach-web/src/routes/scuttlebutt/invites.rs b/peach-web/src/routes/scuttlebutt/invites.rs new file mode 100644 index 0000000..098f04e --- /dev/null +++ b/peach-web/src/routes/scuttlebutt/invites.rs @@ -0,0 +1,94 @@ +use maud::{html, Markup, PreEscaped}; +use peach_lib::sbot::SbotStatus; +use rouille::{post_input, try_or_400, Request, Response}; + +use crate::{ + templates, + utils::{ + flash::{FlashRequest, FlashResponse}, + sbot, + }, +}; + +// ROUTE: /scuttlebutt/invites + +/// Render the invite form template. +fn invite_form_template( + flash_name: Option<&str>, + flash_msg: Option<&str>, + invite_code: Option<&str>, +) -> Markup { + html! { + (PreEscaped("")) + div class="card center" { + form id="invites" class="center" action="/scuttlebutt/invites" method="post" { + div class="center" style="width: 80%;" { + label for="inviteUses" class="label-small font-gray" title="Number of times the invite code can be reused" { "USES" } + input type="number" id="inviteUses" name="uses" min="1" max="150" size="3" value="1"; + @if let Some(code) = invite_code { + p class="card-text" style="margin-top: 1rem; user-select: all;" title="Invite code" { + (code) + } + } + } + (PreEscaped("")) + input id="createInvite" class="button button-primary center" style="margin-top: 1rem;" type="submit" title="Create a new invite code" value="Create"; + a id="cancel" class="button button-secondary center" href="/scuttlebutt/peers" title="Cancel" { "Cancel" } + } + // render flash message if cookies were found in the request + @if let (Some(name), Some(msg)) = (flash_name, flash_msg) { + // avoid displaying the invite code-containing flash msg + @if name != "code" { + (PreEscaped("")) + (templates::flash::build_template(&name, &msg)) + } + } + } + } +} + +/// Scuttlebutt invite template builder. +pub fn build_template(request: &Request) -> PreEscaped { + // check for flash cookies; will be (None, None) if no flash cookies are found + let (flash_name, flash_msg) = request.retrieve_flash(); + + // if flash_name is "code" then flash_msg will be an invite code + let invite_code = if flash_name == Some("code") { + flash_msg + } else { + None + }; + + let invite_form_template = match SbotStatus::read() { + // only render the invite form template if the sbot is active + Ok(status) if status.state == Some("active".to_string()) => { + html! { (invite_form_template(flash_name, flash_msg, invite_code)) } + } + _ => { + // the sbot is not active; render a message instead of the invite form + templates::inactive::build_template("Invite creation is unavailable.") + } + }; + + let body = + templates::nav::build_template(invite_form_template, "Invites", Some("/scuttlebutt/peers")); + + templates::base::build_template(body) +} + +/// Parse the invite uses data and attempt to generate an invite code. +pub fn handle_form(request: &Request) -> Response { + // query the request body for form data + // return a 400 error if the admin_id field is missing + let data = try_or_400!(post_input!(request, { + // the number of times the invite code can be used + uses: u16, + })); + + let (flash_name, flash_msg) = match sbot::create_invite(data.uses) { + Ok(code) => ("flash_name=code".to_string(), format!("flash_msg={}", code)), + Err(e) => ("flash_name=error".to_string(), format!("flash_msg={}", e)), + }; + + Response::redirect_303("/scuttlebutt/invites").add_flash(flash_name, flash_msg) +} diff --git a/peach-web/src/routes/scuttlebutt/mod.rs b/peach-web/src/routes/scuttlebutt/mod.rs index 018aef5..41d4cf9 100644 --- a/peach-web/src/routes/scuttlebutt/mod.rs +++ b/peach-web/src/routes/scuttlebutt/mod.rs @@ -1,4 +1,6 @@ pub mod blocks; pub mod follows; pub mod friends; +pub mod invites; pub mod peers; +pub mod search; diff --git a/peach-web/src/routes/scuttlebutt/search.rs b/peach-web/src/routes/scuttlebutt/search.rs new file mode 100644 index 0000000..b8055c0 --- /dev/null +++ b/peach-web/src/routes/scuttlebutt/search.rs @@ -0,0 +1,66 @@ +use maud::{html, PreEscaped}; +use rouille::{post_input, try_or_400, Request, Response}; + +use crate::{ + templates, + utils::{ + flash::{FlashRequest, FlashResponse}, + sbot, + }, +}; + +// ROUTE: /scuttlebutt/search + +/// Scuttlebutt peer search template builder. +pub fn build_template(request: &Request) -> PreEscaped { + // check for flash cookies; will be (None, None) if no flash cookies are found + let (flash_name, flash_msg) = request.retrieve_flash(); + + let search_template = html! { + (PreEscaped("")) + div class="card center" { + form id="sbotConfig" class="center" action="/scuttlebutt/search" method="post" { + div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Public key (ID) of a peer" { + label for="publicKey" class="label-small font-gray" { "PUBLIC KEY" } + input type="text" id="publicKey" name="public_key" placeholder="@xYz...=.ed25519" autofocus; + } + (PreEscaped("")) + input id="search" class="button button-primary center" type="submit" title="Search for peer" value="Search"; + // render flash message if cookies were found in the request + @if let (Some(name), Some(msg)) = (flash_name, flash_msg) { + (PreEscaped("")) + (templates::flash::build_template(name, msg)) + } + } + } + }; + + let body = + templates::nav::build_template(search_template, "Search", Some("/scuttlebutt/peers")); + + templates::base::build_template(body) +} + +/// Parse the public key, verify that it's valid and then redirect to the +/// profile of the given key. +/// +/// If the public key is invalid, set an error flash message and redirect. +pub fn handle_form(request: &Request) -> Response { + // query the request body for form data + // return a 400 error if the admin_id field is missing + let data = try_or_400!(post_input!(request, { + public_key: String, + })); + + match sbot::validate_public_key(&data.public_key) { + Ok(_) => { + let url = format!("/scuttlebutt/profile?={}", &data.public_key); + Response::redirect_303(url) + } + Err(err) => { + let (flash_name, flash_msg) = + ("flash_name=error".to_string(), format!("flash_msg={}", err)); + Response::redirect_303("/scuttlebutt/search").add_flash(flash_name, flash_msg) + } + } +} diff --git a/peach-web/src/utils/sbot.rs b/peach-web/src/utils/sbot.rs index 5dcf05e..62d449a 100644 --- a/peach-web/src/utils/sbot.rs +++ b/peach-web/src/utils/sbot.rs @@ -46,65 +46,45 @@ pub async fn init_sbot_with_config( Ok(sbot_client) } -// 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_else(|| 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)? - } +// 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()), }; - Ok(go_ssb_path) + + // 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(()) } -// 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(file: &mut TempFile<'_>) -> Result { - // create temporary directory and path - let temp_dir = Directory::new("blob")?; - // we performed a `file.name().is_some()` check before calling `write_blob_to_store` - // so it should be safe to do a simple unwrap here - let filename = file.name().expect("retrieving filename from uploaded file"); - let temp_path = temp_dir.join(filename); - // write file to temporary path - file.persist_to(&temp_path).await?; - // 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) -} -*/ - +/// 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(); @@ -126,6 +106,21 @@ pub fn latest_sequence_number() -> Result> { }) } +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?; + + //TODO: debug!("Generating Scuttlebutt invite code"); + let invite_code = sbot_client.invite_create(uses).await?; + + Ok(invite_code) + }) +} + +/// 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(); @@ -160,6 +155,7 @@ pub fn get_blocks_list() -> Result>, Box> }) } +/// 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(); @@ -207,6 +203,7 @@ pub fn get_follows_list() -> Result>, Box }) } +/// 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(); @@ -268,3 +265,63 @@ pub fn get_friends_list() -> Result>, Box Ok(peer_list) }) } + +// 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_else(|| 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(file: &mut TempFile<'_>) -> Result { + // create temporary directory and path + let temp_dir = Directory::new("blob")?; + // we performed a `file.name().is_some()` check before calling `write_blob_to_store` + // so it should be safe to do a simple unwrap here + let filename = file.name().expect("retrieving filename from uploaded file"); + let temp_path = temp_dir.join(filename); + // write file to temporary path + file.persist_to(&temp_path).await?; + // 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) +} +*/