lykin_tutorial/part_2_subscribe_form
glyph 59947f6166 add missing RelationshipQuery import 2022-10-02 17:11:14 +01:00
..
src include readme as rust lang doc comment 2022-10-02 16:48:17 +01:00
templates Revert "hide parts 2, 3 and 4" 2022-09-05 10:09:00 +01:00
.gitignore Revert "hide parts 2, 3 and 4" 2022-09-05 10:09:00 +01:00
Cargo.toml Revert "hide parts 2, 3 and 4" 2022-09-05 10:09:00 +01:00
README.md add missing RelationshipQuery import 2022-10-02 17:11:14 +01:00
notes Revert "hide parts 2, 3 and 4" 2022-09-05 10:09:00 +01:00

README.md

lykin tutorial

Part 2: Subscription Form and Key Validation

Introduction

In the first part of the tutorial series we created a basic web server and wrote our first Scuttlebutt-related code. This tutorial installment will add an HTML form and route handler(s) to allow peer subscriptions through the web interface. We will learn how to validate public keys submitted via the form and how to check whether or not we follow the peer represented by a submitted key. These additions will pave the way for following and unfollowing peers.

There's a lot of ground to cover today. Let's dive into it.

Outline

Here's what we'll tackle in this second part of the series:

  • Split code into modules
  • Add peer subscription form and routes
  • Validate a public key
  • Add flash messages
  • Check if we are following a peer

Libraries

The following libraries are introduced in this part:

Split Code into Modules

A simple task to begin with: let's create an sbot module and a routes module and reorganise our code from the first part of the tutorial.

src/routes.rs

use rocket::get;

use crate::sbot;

#[get("/")]
pub async fn home() -> String {
    match sbot::whoami().await {
        Ok(id) => id,
        Err(e) => format!("whoami call failed: {}", e),
    }
}

src/sbot.rs

use std::env;

use golgi::{sbot::Keystore, Sbot};

pub async fn init_sbot() -> Result<Sbot, String> {
    let go_sbot_port = env::var("GO_SBOT_PORT").unwrap_or_else(|_| "8021".to_string());

    let keystore = Keystore::GoSbot;
    let ip_port = Some(format!("127.0.0.1:{}", go_sbot_port));
    let net_id = None;

    Sbot::init(keystore, ip_port, net_id)
        .await
        .map_err(|e| e.to_string())
}

pub async fn whoami() -> Result<String, String> {
    let mut sbot = init_sbot().await?;
    sbot.whoami().await.map_err(|e| e.to_string())
}

src/main.rs

mod routes;
mod sbot;

use rocket::{launch, routes};

use crate::routes::*;

#[launch]
async fn rocket() -> _ {
    rocket::build().mount("/", routes![home])
}

Add Peer Subscription Form and Routes

Now that we've taken care of some housekeeping, we can begin adding new functionality. We need a way to accept a public key; this will allow us to subscribe and unsubscribe to the posts of a particular peer. We'll use the Tera templating engine to create HTML templates for our application. Tera is inspired by the Jinja2 template language and is supported by Rocket.

The Tera functionality we require is bundled in the rocket_dyn_templates crate. We can add that to our manifest:

Cargo.toml

rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }

We will modify the Rocket launch code in src/main.rs to attach a template fairing. Fairings are Rocket's approach to structured middleware:

use rocket_dyn_templates::Template;

#[launch]
async fn rocket() -> _ {
    rocket::build()
        .attach(Template::fairing())
        .mount("/", routes![home])
}

Let's create a base template and add a form for submitting a Scuttlebutt public key. First we need to make a templates directory in the root of our lykin project:

mkdir templates

Open a template file for editing (notice the .tera suffix):

templates/base.html.tera

For now we'll write some HTML boilerplate code and a form to accept a public key. We'll use the same form for subscription and unsubscription events. Also notice the {{ whoami }} syntax which allows us to render a variable from the template context (defined in the route handler):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>lykin</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <h1>lykin</h1>
    <p>{{ whoami }}</p>
    <form action="/subscribe" method="post">
      <label for="public_key">Public Key</label>
      <input type="text" id="public_key" name="public_key" maxlength=53>
      <input type="submit" value="Subscribe">
      <input type="submit" value="Unsubscribe" formaction="/unsubscribe">
    </form>
  </body>
</html>

Our home request handler needs to be updated to serve this HTML template:

src/routes.rs

use rocket_dyn_templates::{context, Template};

#[get("/")]
pub async fn home() -> Template {
    let whoami = match sbot::whoami().await {
        Ok(id) => id,
        Err(e) => format!("whoami call failed: {}", e),
    };

    Template::render("base", context! { whoami: whoami })
}

With the form in place, we can write our POST request handlers in our Rocket web server. These request handlers will be responsible for processing the submitted public key and triggering the follow / unfollow calls to the sbot. For now we'll simply use the log library to confirm that the handler(s) have been called before redirecting to the home route.

First add the log dependency to Cargo.toml:

log = "0.4"

Then add the subscription route handlers to the existing code in src/routes.rs:

use log::info;
use rocket::{form::Form, get, post, response::Redirect, uri, FromForm}

#[derive(FromForm)]
pub struct PeerForm {
    pub public_key: String,
}


#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(peer: Form<PeerForm>) -> Redirect {
    info!("Subscribing to peer {}", &peer.public_key);

    Redirect::to(uri!(home))
}

#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(peer: Form<PeerForm>) -> Redirect {
    info!("Unsubscribing to peer {}", &peer.public_key);

    Redirect::to(uri!(home))
}

Finally, we need to register these two new routes in our Rocket launch code:

src/main.rs

#[launch]
async fn rocket() -> _ {
    rocket::build()
        .attach(Template::fairing())
        .mount("/", routes![home, subscribe_form, unsubscribe_form])
}

Run the project with the appropriate log level and ensure that everything is working correctly. You can test this by pasting a public key into the form input and clicking the Subscribe and Unsubscribe buttons.

RUST_LOG=lykin=info cargo run

Validate a Public Key

We can now write some code to validate the input from our subscription form and ensure the data represents a valid Ed25519 Scuttlebutt key. We'll create a utilities module to house this function:

src/utils.rs

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("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("no dot index was found".to_string()),
    };

    // Check the hashing algorithm (must end with ".ed25519").
    if !&public_key.ends_with(".ed25519") {
        return Err("hashing algorithm must be ed25519".to_string());
    }

    // Obtain the base64 portion (substring) of the public key.
    let base64_str = &public_key[1..dot_index];

    // Ensure the length of the base64 encoded ed25519 public key is correct.
    if base64_str.len() != 44 {
        return Err("base64 data length is incorrect".to_string());
    }

    Ok(())
}

The utils module needs to registered in main.rs. Without this addition, the module will not be compiled and the validate_public_key function will not be available to the rest of our program. Add this line at the top of src/main.rs:

mod utils;

Now the validation function can be called from our subscribe / unsubscribe route handlers, allowing us to ensure the provided public key is valid before using it to make further RPC calls to the sbot:

src/routes.rs

use crate::utils;

#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(peer: Form<PeerForm>) -> Redirect {
    info!("Subscribing to peer {}", &peer.public_key);
    if let Err(e) = utils::validate_public_key(&peer.public_key) {
        warn!("Public key {} is invalid: {}", &peer.public_key, e);
    }

    Redirect::to(uri!(home))
}

#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(peer: Form<PeerForm>) -> Redirect {
		info!("Unsubscribing to peer {}", &peer.public_key);
    if let Err(e) = utils::validate_public_key(&peer.public_key) {
        warn!("Public key {} is invalid: {}", &peer.public_key, e);
    }

    Redirect::to(uri!(home))
}

Add Flash Messages

Our log messages are helpful to us during development and production runs but the user of our applications is missing out on valuable information; they will have no idea whether or not the public keys they submit for subscription are valid. Let's add flash message support so we have a means of reporting back to the user via the UI.

Rocket makes this addition very simple, having built-in support for flash message cookies in both the response and request handlers. We will have to update our src/routes.rs file as follows:

use rocket::{request::FlashMessage, response::Flash};

#[get("/")]
// Note the addition of the `flash` parameter.
pub async fn home(flash: Option<FlashMessage<'_>>) -> Template {
    // ...
    
    // The `flash` parameter value is added to the template context data.
    Template::render("base", context! { whoami: whoami, flash: flash })
}

#[post("/subscribe", data = "<peer>")]
// We return a `Result` type instead of a simple `Redirect`.
pub async fn subscribe_form(peer: Form<PeerForm>) -> Result<Redirect, Flash<Redirect>> {
    info!("Subscribing to peer {}", &peer.public_key);
    if let Err(e) = utils::validate_public_key(&peer.public_key) {
        let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
        warn!("Public key {} is invalid: {}", &peer.public_key, e);
        return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
    }

    Ok(Redirect::to(uri!(home)))
}

#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(peer: Form<PeerForm>) -> Result<Redirect, Flash<Redirect>> {
    info!("Unsubscribing to peer {}", &peer.public_key);
    if let Err(e) = utils::validate_public_key(&peer.public_key) {
        let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
        warn!("Public key {} is invalid: {}", &peer.public_key, e);
        return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
    }

    Ok(Redirect::to(uri!(home)))
}

From the code changes we've made above we can see that a successful key validation will simply result in a redirect to the home page, while an error during key validation will result in a redirect with the addition of a flash message cookie. Now we need to update our HTML template to show any error flash messages which might be set.

templates/base.html.tera

Add the following code below the </form> tag:

{% if flash and flash.kind == "error" %}
<p style="color: red;">[ {{ flash.message }} ]</p>
{% endif %}

Now, if a submitted public key is invalid, a red error message will be displayed below the form - informing the application user of the error.

Check Peer Follow Status

OK, that's a lot of web application shenanigans but I know you're really here for the Scuttlebutt goodness. Let's close-out this installment by writing a function to check whether or not the peer represented by our local go-sbot instance follows another peer; in simpler words: do we follow a peer account or not?

In order to do this using the golgi RPC library, we have to construct a RelationshipQuery struct and call the friends_is_following() method. Let's add a convenience function to initialise the sbot, construct the query and call friends_is_following() RPC method:

src/sbot.rs

use golgi::api::friends::RelationshipQuery;

pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
    let mut sbot = init_sbot().await?;

    let query = RelationshipQuery {
        source: public_key_a.to_string(),
        dest: public_key_b.to_string(),
    };

    sbot.friends_is_following(query)
        .await
        .map_err(|e| e.to_string())
}

When calling is_following(), we are asking: "does the peer represented by public_key_a follow the peer represented by public_key_b?" The returned value may be Ok("true"), Ok("false") or an error. Let's add these queries to our subscribe and unsubscribe route handlers:

src/routes.rs

#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(peer: Form<PeerForm>) -> Result<Redirect, Flash<Redirect>> {
    if let Err(e) = utils::validate_public_key(&peer.public_key) {
        let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
        warn!("{}", validation_err_msg);
        return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
    } else {
        info!("Public key {} is valid", &peer.public_key);
        // Retrieve the value of the local public key by calling `whoami`.
        if let Ok(whoami) = sbot::whoami().await {
            // Do we follow the peer represented by the submitted public key?
            match sbot::is_following(&whoami, &peer.public_key).await {
                Ok(status) if status.as_str() == "false" => {
                    info!("Not currently following peer {}", &peer.public_key);
                    // This is where we will initiate a follow in the next
                    // installment of the tutorial series.
                }
                Ok(status) if status.as_str() == "true" => {
                    info!(
                        "Already following peer {}. No further action taken",
                        &peer.public_key
                    )
                }
                _ => (),
            }
        } else {
            warn!("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again")
        }
    }

    Ok(Redirect::to(uri!(home)))
}

#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(peer: Form<PeerForm>) -> Result<Redirect, Flash<Redirect>> {
    if let Err(e) = utils::validate_public_key(&peer.public_key) {
        let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
        warn!("{}", validation_err_msg);
        return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
    } else {
        info!("Public key {} is valid", &peer.public_key);
        if let Ok(whoami) = sbot::whoami().await {
            match sbot::is_following(&whoami, &peer.public_key).await {
                Ok(status) if status.as_str() == "true" => {
                    info!("Currently following peer {}", &peer.public_key);
                }
                Ok(status) if status.as_str() == "false" => {
                    info!(
                        "Not currently following peer {}. No further action taken",
                        &peer.public_key
                    );
                }
                _ => (),
            }
        } else {
            warn!("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again")
        }
    }

    Ok(Redirect::to(uri!(home)))
}

The code above is quite verbose due to the fact that we are matching on multiple possibilities. We could just as easily ignore the "already following" case in the subscription handler and the "not following" case in the unsubscription handler. The real star of the show is the sbot method: sbot::is_following(peer_a, peer_b).

Conclusion

Today we did a lot of work to make our project a more complete web application. We improved the organisation of our codebase by splitting it into modules, added an HTML form and handlers to enable peer subscription events, learned how to validate public keys and query follow status, and added flash message support to be able to report errors via the UI.

If you're confused by any of the code samples above, remember that you can see the complete code for this installment in the git repo.

In the next installment we'll add a key-value store and learn how to follow and unfollow Scuttlebutt peers.

Funding

This work has been funded by a Scuttlebutt Community Grant.

Contributions

I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to my Liberapay account to support me in my coding and cultivation efforts.