parent
ce693cadc3
commit
54874c61d8
1
part_2_subscribe_form/.gitignore
vendored
Normal file
1
part_2_subscribe_form/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
10
part_2_subscribe_form/Cargo.toml
Normal file
10
part_2_subscribe_form/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "part_2_subscribe_form"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
|
||||
log = "0.4"
|
||||
rocket = "0.5.0-rc.1"
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
|
436
part_2_subscribe_form/README.md
Normal file
436
part_2_subscribe_form/README.md
Normal file
@ -0,0 +1,436 @@
|
||||
# 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:
|
||||
|
||||
- [`log`](https://crates.io/crates/log)
|
||||
- [`rocket_dyn_templates`](https://crates.io/crates/rocket_dyn_templates)
|
||||
|
||||
### 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`
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
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](https://tera.netlify.app/) to create HTML templates for our application. Tera is inspired by the [Jinja2 template language](https://jinja.palletsprojects.com/en/3.0.x/) and is supported by [Rocket](https://rocket.rs/).
|
||||
|
||||
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:
|
||||
|
||||
```rust
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
rocket::build()
|
||||
.attach(Template::fairing())
|
||||
.mount("/", routes![home, subscribe_form, unsubscribe_form])
|
||||
}
|
||||
```
|
||||
|
||||
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):
|
||||
|
||||
```html
|
||||
<!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`
|
||||
|
||||
```rust
|
||||
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`:
|
||||
|
||||
```rust
|
||||
use log::info;
|
||||
use rocket::{form::Form, get, post, response::Redirect, uri, FromFor}
|
||||
|
||||
#[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`
|
||||
|
||||
```rust
|
||||
#[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`
|
||||
|
||||
```rust
|
||||
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(())
|
||||
}
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
```rust
|
||||
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](https://api.rocket.rs/v0.5-rc/rocket/response/struct.Flash.html) in both the response and request handlers. We will have to update our `src/routes.rs` file as follows:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```html
|
||||
{% 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`
|
||||
|
||||
```rust
|
||||
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`
|
||||
|
||||
```rust
|
||||
#[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.
|
7
part_2_subscribe_form/notes
Normal file
7
part_2_subscribe_form/notes
Normal file
@ -0,0 +1,7 @@
|
||||
[ part 2 ]
|
||||
|
||||
- start by splitting sbot and routes into modules
|
||||
- add html form to subscribe to a peer (tera template)
|
||||
- add route for subscription form submission
|
||||
- validate public key
|
||||
- add flash message
|
15
part_2_subscribe_form/src/main.rs
Normal file
15
part_2_subscribe_form/src/main.rs
Normal file
@ -0,0 +1,15 @@
|
||||
mod routes;
|
||||
mod sbot;
|
||||
mod utils;
|
||||
|
||||
use rocket::{launch, routes};
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
use crate::routes::*;
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
rocket::build()
|
||||
.attach(Template::fairing())
|
||||
.mount("/", routes![home, subscribe_form, unsubscribe_form])
|
||||
}
|
84
part_2_subscribe_form/src/routes.rs
Normal file
84
part_2_subscribe_form/src/routes.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use log::{info, warn};
|
||||
use rocket::{
|
||||
form::Form,
|
||||
get, post,
|
||||
request::FlashMessage,
|
||||
response::{Flash, Redirect},
|
||||
uri, FromForm,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
|
||||
use crate::{sbot, utils};
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct PeerForm {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn home(flash: Option<FlashMessage<'_>>) -> Template {
|
||||
let whoami = match sbot::whoami().await {
|
||||
Ok(id) => id,
|
||||
Err(e) => format!("Error making `whoami` RPC call: {}. Please ensure the local go-sbot is running and refresh.", e),
|
||||
};
|
||||
|
||||
Template::render("base", context! { whoami: whoami, flash: flash })
|
||||
}
|
||||
|
||||
#[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);
|
||||
if let Ok(whoami) = sbot::whoami().await {
|
||||
match sbot::is_following(&whoami, &peer.public_key).await {
|
||||
Ok(status) if status.as_str() == "false" => {
|
||||
info!("Not currently following peer {}", &peer.public_key);
|
||||
}
|
||||
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)))
|
||||
}
|
39
part_2_subscribe_form/src/sbot.rs
Normal file
39
part_2_subscribe_form/src/sbot.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use std::env;
|
||||
|
||||
use golgi::{api::friends::RelationshipQuery, sbot::Keystore, Sbot};
|
||||
|
||||
/// Initialise a connection to a Scuttlebutt server.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Return the public key of the local sbot instance.
|
||||
pub async fn whoami() -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.whoami().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Check follow status.
|
||||
///
|
||||
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
|
||||
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())
|
||||
}
|
32
part_2_subscribe_form/src/utils.rs
Normal file
32
part_2_subscribe_form/src/utils.rs
Normal file
@ -0,0 +1,32 @@
|
||||
//! Public key validation.
|
||||
|
||||
/// 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("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(())
|
||||
}
|
21
part_2_subscribe_form/templates/base.html.tera
Normal file
21
part_2_subscribe_form/templates/base.html.tera
Normal file
@ -0,0 +1,21 @@
|
||||
<!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><a href="/">lykin</a></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>
|
||||
{% if flash and flash.kind == "error" %}
|
||||
<p style="color: red;">[ {{ flash.message }} ]</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
1
part_3_database_follows/.gitignore
vendored
Normal file
1
part_3_database_follows/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
16
part_3_database_follows/Cargo.toml
Normal file
16
part_3_database_follows/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "part_3_database_follows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
bincode = "1.3"
|
||||
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
|
||||
log = "0.4"
|
||||
rocket = "0.5.0-rc.1"
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
|
||||
serde = "1"
|
||||
sled = "0.34"
|
||||
xdg = "2.4.1"
|
519
part_3_database_follows/README.md
Normal file
519
part_3_database_follows/README.md
Normal file
@ -0,0 +1,519 @@
|
||||
# lykin tutorial
|
||||
|
||||
## Part 3: Database and Follows
|
||||
|
||||
### Introduction
|
||||
|
||||
Having learned how to make follow-graph queries in part two, this tutorial installment will demonstrate how to follow and unfollow Scuttlebutt peers and how to query the name of a peer. In addition to the Scuttlebutt-related code, we will create a key-value database to store a list of peers to whom we are subscribed. The subscription logic of our application will be largely complete by the end of this installment.
|
||||
|
||||
### Outline
|
||||
|
||||
Here's what we'll tackle in this third part of the series:
|
||||
|
||||
- Setup a key-value database
|
||||
- Create a peer data structure
|
||||
- Add and remove peers from the database
|
||||
- Follow and unfollow a peer
|
||||
- Get the name of a peer
|
||||
- Extract follow / unfollow logic
|
||||
- Pass database instance to route handlers
|
||||
- Complete the subscribe / unsubscribe flow
|
||||
|
||||
### Libraries
|
||||
|
||||
The following libraries are introduced in this part:
|
||||
|
||||
- [`bincode`](https://crates.io/crates/bincode)
|
||||
- [`serde`](https://crates.io/crates/serde)
|
||||
- [`sled`](https://crates.io/crates/sled)
|
||||
- [`xdg`](https://crates.io/crates/xdg)
|
||||
|
||||
### Setup a Key-Value Database
|
||||
|
||||
We're going to use [sled](https://sled.rs/) in order to store the data used by our application; namely, peer and post-related data. Sled is a transactional embedded database written in pure Rust. We'll create a separate module for the database code to continue our trend of keeping code separate and organised. Let's begin by creating a `struct` to store our database instance and peer tree. We will also implement an initialisation method for the database:
|
||||
|
||||
`src/db.rs`
|
||||
|
||||
```rust
|
||||
use sled::{Db, Tree};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
// The sled database instance.
|
||||
db: Db,
|
||||
// A database tree for all the peers we are subscribed to.
|
||||
peer_tree: Tree,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
// Initialise the database by opening the database file, loading the
|
||||
// peers tree and returning an instantiated Database struct.
|
||||
pub fn init(path: &Path) -> Self {
|
||||
// Open the database at the given path.
|
||||
// The database will be created if it does not yet exist.
|
||||
let db = sled::open(path).expect("Failed to open database");
|
||||
// Open a database tree with the name "peers".
|
||||
let peer_tree = db
|
||||
.open_tree("peers")
|
||||
.expect("Failed to open 'peers' database tree");
|
||||
|
||||
Database { db, peer_tree }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The initialisation method requires a `Path` in order to open / create the database. We can use the [xdg](https://crates.io/crates/xdg) crate to generate a path using the XDG Base Directory specification. Open `src/main.rs` and add the following code:
|
||||
|
||||
```rust
|
||||
mod db;
|
||||
|
||||
use xdg::BaseDirectories.
|
||||
|
||||
use crate::{db::Database, routes::*};
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
// Define "lykin" as the prefix for the base directories.
|
||||
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
|
||||
// Generate a configuration file path named "database".
|
||||
// On Linux, the path will be `~/.config/lykin/database`.
|
||||
let db_path = xdg_dirs
|
||||
.place_config_file("database")
|
||||
.expect("cannot create database directory");
|
||||
|
||||
// Create the key-value database.
|
||||
let db = Database::init(&db_path);
|
||||
|
||||
rocket::build()
|
||||
// Add the database instance to the Managed State of our Rocket
|
||||
// application. This allows us to access the database from inside
|
||||
// of our route handlers.
|
||||
.manage(db)
|
||||
.attach(Template::fairing())
|
||||
.mount("/", routes![home, subscribe_form, unsubscribe_form])
|
||||
}
|
||||
```
|
||||
|
||||
### Create a Peer Data Structure
|
||||
|
||||
Now that we've initialised our database and have a place to store peer data, we can define the shape of that data by creating a `Peer` struct. For now we'll simply be storing the public key and name of each peer. Add this code to what we already have in `src/db.rs`:
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Scuttlebutt peer data.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Peer {
|
||||
pub public_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
|
||||
In addition to the datastructure itself, we'll implement a couple of methods to be able to create and modify instances of the `struct`.
|
||||
|
||||
`src/db.rs`
|
||||
|
||||
```rust
|
||||
impl Peer {
|
||||
// Create a new instance of the Peer struct using the given public
|
||||
// key. A default value is set for name.
|
||||
pub fn new(public_key: &str) -> Peer {
|
||||
Peer {
|
||||
public_key: public_key.to_string(),
|
||||
name: "".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Modify the name field of an instance of the Peer struct, leaving
|
||||
// the other values unchanged.
|
||||
pub fn set_name(self, name: &str) -> Peer {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Add and Remove Peers from Database
|
||||
|
||||
Let's extend the implementation of `Database` to include methods for adding and removing peers:
|
||||
|
||||
`src/db.rs`
|
||||
|
||||
|
||||
```rust
|
||||
use sled::{IVec, Result};
|
||||
|
||||
impl Database {
|
||||
pub fn init(path: &Path) -> Self {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Add a peer to the database by inserting the public key into the peer
|
||||
// tree.
|
||||
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
|
||||
// Serialise peer data as bincode.
|
||||
let peer_bytes = bincode::serialize(&peer).unwrap();
|
||||
|
||||
// Insert the serialised peer data into the 'peers' database tree,
|
||||
// using the public key of the peer as the key for the database entry.
|
||||
self.peer_tree.insert(&peer.public_key, peer_bytes)
|
||||
}
|
||||
|
||||
// Remove a peer from the database, as represented by the given public
|
||||
// key.
|
||||
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
|
||||
self.peer_tree.remove(&public_key).map(|_| ())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You'll notice in the above code snippet that we're serialising the peer data as bincode before inserting it. The sled database we're using expects values in the form of a byte vector; bincode thus provides a neat way of storing complex datastructures (such as our `Peer` `struct`).
|
||||
|
||||
That's enough database code for the moment. Now we can return to our Scuttlebutt-related code and complete the peer subscription flows.
|
||||
|
||||
### Follow / Unfollow a Peer
|
||||
|
||||
Let's open the `src/sbot.rs` module and write the functions we need to be able to follow and unfollow Scuttlebutt peers. Each function will simply take the public key of the peer whose relationship we wish to change:
|
||||
|
||||
```rust
|
||||
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
sbot.follow(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
```
|
||||
|
||||
The `Ok(_)` variant of the returned `Result` type will contain the message reference of the published follow / unfollow message. Once again, we are transforming any possible error to a `String` for easier handling and reporting in the caller function.
|
||||
|
||||
At this point we have the capability to check whether we follow a peer, to add and remove peer data to our key-value store, and to follow and unfollow a peer. Casting our minds back to the `subscribe` and `unsubscribe` route handlers of our webserver, we can now add calls to `follow_peer()` and `unfollow_peer()`:
|
||||
|
||||
`src/routes.rs`
|
||||
|
||||
```rust
|
||||
// Update this match block in `subscribe_form`
|
||||
match sbot::is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "false" => {
|
||||
// If we are not following the peer, call the `follow_peer` method.
|
||||
match sbot::follow_peer(remote_peer).await {
|
||||
Ok(_) => info!("Followed peer {}", &remote_peer),
|
||||
Err(e) => warn!("Failed to follow peer {}: {}", &remote_peer, e),
|
||||
}
|
||||
}
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!(
|
||||
"Already following peer {}. No further action taken",
|
||||
&remote_peer
|
||||
)
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
// Update this match block in `unsubscribe_form`
|
||||
match sbot::is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
// If we are following the peer, call the `unfollow_peer` method.
|
||||
info!("Unfollowing peer {}", &remote_peer);
|
||||
match sbot::unfollow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Unfollowed peer {}", &remote_peer);
|
||||
}
|
||||
Err(e) => warn!("Failed to unfollow peer {}: {}", &remote_peer, e),
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
```
|
||||
|
||||
Excellent. We're now able to initiate follow and unfollow actions via the web interface of our application. Checking the state of our relationship with the peer helps to prevent publishing unnecessary follow / unfollow messages. There is no need to publish an additional follow message if we already follow a peer.
|
||||
|
||||
We're almost ready to start adding and removing peers to our key-value store each time a `subscribe` or `unsubscribe` form action is submitted. Before we can do that we need to be able to query the name of a peer.
|
||||
|
||||
### Get Peer Name
|
||||
|
||||
Querying the name of a Scuttlebutt peer is just as simple as following or unfollowing:
|
||||
|
||||
`src/sbot.rs`
|
||||
|
||||
```rust
|
||||
pub async fn get_name(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
sbot.get_name(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
```
|
||||
|
||||
As usual, we initialise a connection with the sbot and then make our method call. This method will either return the name of a peer or the public key of the peer. The public key is returned if the sbot does not have a name stored in its indexes; this can happen if the peer is out of range of our follow graph, for example. You've probably seen this behaviour in your favourite Scuttlebutt client...sometimes it takes a while to receive an `about` message containing an assigned name for a peer.
|
||||
|
||||
### Extract Follow / Unfollow Logic
|
||||
|
||||
Now let's go back to our subscribe and unsubscribe route handlers and separate some of the Scuttlebutt control flow out into the `sbot` module. Separating concerns like this will help to bring greater clarity to the handler functions.
|
||||
|
||||
Add the following two function to `src/sbot.rs`. You'll notice that we're using a `Result` return type for each function. This will allow us to match on the outcome in our route handlers and report back to the UI. The logging makes the functions look very busy but the sbot actions tell the story.
|
||||
|
||||
`src/sbot.rs`
|
||||
|
||||
```rust
|
||||
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
|
||||
if let Ok(whoami) = whoami().await {
|
||||
match is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "false" => {
|
||||
match follow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Followed peer {}", &remote_peer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = warn!("Failed to follow peer {}: {}", &remote_peer, e);
|
||||
warn!("{}", err_msg);
|
||||
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!(
|
||||
"Already following peer {}. No further action taken",
|
||||
&remote_peer
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(
|
||||
"Failed to determine follow status: received unrecognised response from local sbot"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
|
||||
warn!("{}", err_msg);
|
||||
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unfollow_if_following(remote_peer: &str) {
|
||||
if let Ok(whoami) = whoami().await {
|
||||
match is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!("Unfollowing peer {}", &remote_peer);
|
||||
match unfollow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Unfollowed peer {}", &remote_peer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
|
||||
warn!("{}", err_msg);
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
_ => Err(
|
||||
"Failed to determine follow status: received unrecognised response from local sbot"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
|
||||
warn!("{}", err_msg);
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now we can remove the follow / unfollow logic from our route handlers and call `sbot::follow_if_not_following()` and `sbot::unfollow_if_following()` instead:
|
||||
|
||||
`src/routes.rs`
|
||||
|
||||
```rust
|
||||
#[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);
|
||||
match sbot::follow_if_not_following(&peer.public_key).await {
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
match sbot::unfollow_if_following(&peer.public_key).await {
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
```
|
||||
|
||||
### Pass Database Instance to Route Handlers
|
||||
|
||||
We are about to add some database interactions to the code in our `/subscribe` and `/unsubscribe` route handlers. If you recall, we added an instance of our database to the managed state of our Rocket application at the beginning of this tutorial; we instantiated the database and called `rocket::build.manage(db)` in `src/main.rs`. By doing so we gained the ability to access the database from our route handlers. The final requirement is that we add the `db` as a parameter in the function signature of each handler (`db: &State<Database>`):
|
||||
|
||||
`src/routes.rs`
|
||||
|
||||
```rust
|
||||
use rocket::State;
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
#[post("/subscribe", data = "<peer>")]
|
||||
pub async fn subscribe_form(db: &State<Database>, peer: Form<PeerForm>) -> Result<Redirect, Flash<Redirect>> {
|
||||
// ...
|
||||
}
|
||||
|
||||
#[post("/unsubscribe", data = "<peer>")]
|
||||
pub async fn unsubscribe_form(db: &State<Database>, peer: Form<PeerForm>) -> Result<Redirect, Flash<Redirect>> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Complete the Subscribe / Unsubscribe Flow
|
||||
|
||||
We now have all the pieces we need to complete the subscribe and unsubscribe actions for our web application. Before modifying the code, here's a simple outline of what each handler will do (assuming the "happy path" occurs and no errors are generated):
|
||||
|
||||
```text
|
||||
/subscribe
|
||||
|
||||
-> validate public key of peer
|
||||
-> get name of peer
|
||||
-> follow peer if not following
|
||||
-> add peer (public key and name) to database
|
||||
|
||||
/unsubscribe
|
||||
|
||||
-> validate public key of peer
|
||||
-> unfollow peer if following
|
||||
-> remove peer from database
|
||||
```
|
||||
|
||||
Let's add the `get_name()`, `add_peer()` and `remove_peer()` logic.
|
||||
|
||||
`src/routes.rs`
|
||||
|
||||
```rust
|
||||
#[post("/subscribe", data = "<peer>")]
|
||||
pub async fn subscribe_form(db: &State<Database>, 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 name of the peer to which we are subscribing.
|
||||
let peer_name = match sbot::get_name(&peer.public_key).await {
|
||||
Ok(name) => name,
|
||||
Err(e) => {
|
||||
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
|
||||
// Return an empty string if an error occurs.
|
||||
String::from("")
|
||||
}
|
||||
};
|
||||
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
|
||||
|
||||
match sbot::follow_if_not_following(&peer.public_key).await {
|
||||
Ok(_) => {
|
||||
// Add the peer to the database.
|
||||
if db.add_peer(peer_info).is_ok() {
|
||||
info!("Added {} to 'peers' database tree", &peer.public_key);
|
||||
} else {
|
||||
let err_msg = format!(
|
||||
"Failed to add peer {} to 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
warn!("{}", err_msg);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
|
||||
#[post("/unsubscribe", data = "<peer>")]
|
||||
pub async fn unsubscribe_form(db: &State<Database>, 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);
|
||||
|
||||
match sbot::unfollow_if_following(&peer.public_key).await {
|
||||
Ok(_) => {
|
||||
// Remove the peer from the database.
|
||||
if db.remove_peer(&peer.public_key).is_ok() {
|
||||
info!(
|
||||
"Removed peer {} from 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Failed to remove peer {} from 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
```
|
||||
|
||||
At this point it's a good idea to run the code and experiment with subscribing and unsubscribing to peers. Remember to set the `RUST_LOG` environment variable so you can view the output as you interact with the application:
|
||||
|
||||
`RUST_LOG=info cargo run`
|
||||
|
||||
### Conclusion
|
||||
|
||||
In this installment we added an important pillar of our application: the key-value database. We added code to instantiate the database and created a `Peer` datastructure to store data about each peer we subscribe to. We also added methods for adding and removing peers from the database. By leveraging Rocket's managed state, we exposed our instantiated database to the code in our route handlers.
|
||||
|
||||
In addition to all of the database-related work, we added Scuttlebutt code to follow, unfollow and retrieve the name for a peer. Such actions are fundamental to any social Scuttlebutt application you may want to write.
|
||||
|
||||
Finally, we put all the pieces together and completed the workflow for our subscription and unsubscription routes. Well done for making it this far!
|
||||
|
||||
In the next installment we'll deal primarily with Scuttlebutt messages - learning how to get all the messages authored by a peer, as well as how to filter down to post-type messages and add them to our key-value database.
|
||||
|
||||
## Funding
|
||||
|
||||
This work has been funded by a Scuttlebutt Community Grant.
|
10
part_3_database_follows/notes
Normal file
10
part_3_database_follows/notes
Normal file
@ -0,0 +1,10 @@
|
||||
[ part 3 ]
|
||||
|
||||
- setup sled database
|
||||
- create basic peer struct
|
||||
- follow if not following
|
||||
- get peer name
|
||||
- add peer to database
|
||||
- unsubscribe
|
||||
- remove peer from database
|
||||
- unfollow if following
|
81
part_3_database_follows/src/db.rs
Normal file
81
part_3_database_follows/src/db.rs
Normal file
@ -0,0 +1,81 @@
|
||||
use std::path::Path;
|
||||
|
||||
use log::{debug, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sled::{Db, IVec, Result, Tree};
|
||||
|
||||
/// Scuttlebutt peer data.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Peer {
|
||||
pub public_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
/// Create a new instance of the Peer struct using the given public
|
||||
/// key. A default value is set for name.
|
||||
pub fn new(public_key: &str) -> Peer {
|
||||
Peer {
|
||||
public_key: public_key.to_string(),
|
||||
name: "".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify the name field of an instance of the Peer struct, leaving
|
||||
/// the other values unchanged.
|
||||
pub fn set_name(self, name: &str) -> Peer {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance of the key-value database and relevant trees.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
/// The sled database instance.
|
||||
db: Db,
|
||||
/// A database tree containing Peer struct instances for all the peers
|
||||
/// we are subscribed to.
|
||||
peer_tree: Tree,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Initialise the database by opening the database file, loading the
|
||||
/// peers tree and returning an instantiated Database struct.
|
||||
pub fn init(path: &Path) -> Self {
|
||||
// Open the database at the given path.
|
||||
// The database will be created if it does not yet exist.
|
||||
// This code will panic if an IO error is encountered.
|
||||
info!("Initialising sled database");
|
||||
let db = sled::open(path).expect("Failed to open database");
|
||||
debug!("Opening 'peers' database tree");
|
||||
let peer_tree = db
|
||||
.open_tree("peers")
|
||||
.expect("Failed to open 'peers' database tree");
|
||||
|
||||
Database { db, peer_tree }
|
||||
}
|
||||
|
||||
/// Add a peer to the database by inserting the public key into the peer
|
||||
/// tree.
|
||||
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
|
||||
debug!("Serializing peer data for {} to bincode", &peer.public_key);
|
||||
let peer_bytes = bincode::serialize(&peer).unwrap();
|
||||
|
||||
debug!(
|
||||
"Inserting peer {} into 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
self.peer_tree.insert(&peer.public_key, peer_bytes)
|
||||
}
|
||||
|
||||
/// Remove a peer from the database, as represented by the given public
|
||||
/// key.
|
||||
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
|
||||
debug!("Removing peer {} from 'peers' database tree", &public_key);
|
||||
self.peer_tree.remove(&public_key).map(|_| ())
|
||||
}
|
||||
}
|
25
part_3_database_follows/src/main.rs
Normal file
25
part_3_database_follows/src/main.rs
Normal file
@ -0,0 +1,25 @@
|
||||
mod db;
|
||||
mod routes;
|
||||
mod sbot;
|
||||
mod utils;
|
||||
|
||||
use rocket::{launch, routes};
|
||||
use rocket_dyn_templates::Template;
|
||||
use xdg::BaseDirectories;
|
||||
|
||||
use crate::{db::Database, routes::*};
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
// Create the key-value database.
|
||||
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
|
||||
let db_path = xdg_dirs
|
||||
.place_config_file("database")
|
||||
.expect("cannot create database directory");
|
||||
let db = Database::init(&db_path);
|
||||
|
||||
rocket::build()
|
||||
.manage(db)
|
||||
.attach(Template::fairing())
|
||||
.mount("/", routes![home, subscribe_form, unsubscribe_form])
|
||||
}
|
141
part_3_database_follows/src/routes.rs
Normal file
141
part_3_database_follows/src/routes.rs
Normal file
@ -0,0 +1,141 @@
|
||||
use log::{info, warn};
|
||||
use rocket::{
|
||||
form::Form,
|
||||
get, post,
|
||||
request::FlashMessage,
|
||||
response::{Flash, Redirect},
|
||||
uri, FromForm, State,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
|
||||
use crate::{
|
||||
db::{Database, Peer},
|
||||
sbot, utils,
|
||||
};
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct PeerForm {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn home(flash: Option<FlashMessage<'_>>) -> Template {
|
||||
let whoami = match sbot::whoami().await {
|
||||
Ok(id) => id,
|
||||
Err(e) => format!("Error making `whoami` RPC call: {}. Please ensure the local go-sbot is running and refresh.", e),
|
||||
};
|
||||
|
||||
Template::render("base", context! { whoami: whoami, flash: flash })
|
||||
}
|
||||
|
||||
#[post("/subscribe", data = "<peer>")]
|
||||
pub async fn subscribe_form(
|
||||
db: &State<Database>,
|
||||
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 name of the peer to which we are subscribing.
|
||||
let peer_name = match sbot::get_name(&peer.public_key).await {
|
||||
Ok(name) => name,
|
||||
Err(e) => {
|
||||
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
|
||||
// Return an empty string if an error occurs.
|
||||
String::from("")
|
||||
}
|
||||
};
|
||||
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
|
||||
|
||||
match sbot::follow_if_not_following(&peer.public_key).await {
|
||||
Ok(_) => {
|
||||
// Add the peer to the database.
|
||||
if db.add_peer(peer_info).is_ok() {
|
||||
info!("Added {} to 'peers' database tree", &peer.public_key);
|
||||
} else {
|
||||
let err_msg = format!(
|
||||
"Failed to add peer {} to 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
warn!("{}", err_msg);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
|
||||
#[post("/unsubscribe", data = "<peer>")]
|
||||
pub async fn unsubscribe_form(
|
||||
db: &State<Database>,
|
||||
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);
|
||||
match sbot::unfollow_if_following(&peer.public_key).await {
|
||||
Ok(_) => {
|
||||
// Remove the peer from the database.
|
||||
if db.remove_peer(&peer.public_key).is_ok() {
|
||||
info!(
|
||||
"Removed peer {} from 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Failed to remove peer {} from 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
|
||||
/*
|
||||
#[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);
|
||||
sbot::follow_if_not_following(&peer.public_key).await;
|
||||
}
|
||||
|
||||
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);
|
||||
sbot::unfollow_if_following(&peer.public_key).await;
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
*/
|
130
part_3_database_follows/src/sbot.rs
Normal file
130
part_3_database_follows/src/sbot.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use std::env;
|
||||
|
||||
use golgi::{api::friends::RelationshipQuery, sbot::Keystore, Sbot};
|
||||
use log::{info, warn};
|
||||
|
||||
/// Initialise a connection to a Scuttlebutt server.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Return the public key of the local sbot instance.
|
||||
pub async fn whoami() -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.whoami().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Check follow status.
|
||||
///
|
||||
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
|
||||
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())
|
||||
}
|
||||
|
||||
/// Follow a peer.
|
||||
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.follow(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Unfollow a peer.
|
||||
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Return the name (self-identifier) for the peer associated with the given
|
||||
/// public key.
|
||||
///
|
||||
/// The public key of the peer will be returned if a name is not found.
|
||||
pub async fn get_name(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.get_name(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Check the follow status of a remote peer and follow them if not already
|
||||
/// following.
|
||||
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
|
||||
if let Ok(whoami) = whoami().await {
|
||||
match is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Followed peer {}", &remote_peer);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
},
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!(
|
||||
"Already following peer {}. No further action taken",
|
||||
&remote_peer
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(
|
||||
"Failed to determine follow status: received unrecognised response from local sbot"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the follow status of a remote peer and unfollow them if already
|
||||
/// following.
|
||||
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
|
||||
if let Ok(whoami) = whoami().await {
|
||||
match is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!("Unfollowing peer {}", &remote_peer);
|
||||
match unfollow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Unfollowed peer {}", &remote_peer);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(
|
||||
"Failed to determine follow status: received unrecognised response from local sbot"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
32
part_3_database_follows/src/utils.rs
Normal file
32
part_3_database_follows/src/utils.rs
Normal file
@ -0,0 +1,32 @@
|
||||
//! Public key validation.
|
||||
|
||||
/// 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("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(())
|
||||
}
|
21
part_3_database_follows/templates/base.html.tera
Normal file
21
part_3_database_follows/templates/base.html.tera
Normal file
@ -0,0 +1,21 @@
|
||||
<!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><a href="/">lykin</a></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>
|
||||
{% if flash and flash.kind == "error" %}
|
||||
<p style="color: red;">[ {{ flash.message }} ]</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
1
part_4_posts_streams/.gitignore
vendored
Normal file
1
part_4_posts_streams/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
16
part_4_posts_streams/Cargo.toml
Normal file
16
part_4_posts_streams/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "part_4_posts_streams"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
bincode = "1.3"
|
||||
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
|
||||
log = "0.4"
|
||||
rocket = "0.5.0-rc.1"
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
|
||||
serde = "1"
|
||||
sled = "0.34"
|
||||
xdg = "2.4.1"
|
316
part_4_posts_streams/README.md
Normal file
316
part_4_posts_streams/README.md
Normal file
@ -0,0 +1,316 @@
|
||||
# lykin tutorial
|
||||
|
||||
## Part 4: Posts and Message Streams
|
||||
|
||||
### Introduction
|
||||
|
||||
In the last installment we completed the subscribe / unsubscribe flow of our application while learning some new Scuttlebutt RPC methods (`is_following()`, `follow()`, `unfollow()` and `get_name()`) and creating a simple key-value database. Today we'll extend our database by adding the ability to store Scuttlebutt post-type messages. We'll also learn how to create a stream of all Scuttlebutt messages authored by a peer and how to filter those messages by type. The work we do in this installment will pave the way for populating our key-value database with posts made by the peers we subscribe to. Let's get into it!
|
||||
|
||||
### Outline
|
||||
|
||||
Here's what we'll tackle in this fourth part of the series:
|
||||
|
||||
- Create a post data structure
|
||||
- Create a stream of Scuttlebutt messages
|
||||
- Filter post-type Scuttlebutt messages
|
||||
- Initialise a database tree for posts
|
||||
- Add a post to the database
|
||||
- Add a post batch to the database
|
||||
|
||||
### Create a Post Data Structure
|
||||
|
||||
We'll begin by creating a `Post` struct to store data about each Scuttlebutt post we want to render in our application. The fields of our struct will diverge from the fields we expect in a Scuttlebutt post-type message. Open `src/db.rs` and add the following code (I've included code comments to further define each field):
|
||||
|
||||
```rust
|
||||
// The text and metadata of a Scuttlebutt root post.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Post {
|
||||
// The key of the post-type message, also known as a message reference.
|
||||
pub key: String,
|
||||
// The text of the post (may be formatted as markdown).
|
||||
pub text: String,
|
||||
// The date the post was published (e.g. 17 May 2021).
|
||||
pub date: String,
|
||||
// The sequence number of the post-type message.
|
||||
pub sequence: u64,
|
||||
// The read state of the post; true if read, false if unread.
|
||||
pub read: bool,
|
||||
// The timestamp representing the date the post was published.
|
||||
pub timestamp: i64,
|
||||
// The subject of the post, represented as the first 53 characters of
|
||||
// the post text.
|
||||
pub subject: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Note that the fields of our `Post` struct diverge from the fields of a Scuttlebutt message. Here's a post-type message from the Scuttlebutt Protocol Guide for comparison:
|
||||
|
||||
```json
|
||||
{
|
||||
"previous": "%XphMUkWQtomKjXQvFGfsGYpt69sgEY7Y4Vou9cEuJho=.sha256",
|
||||
"author": "@FCX/tsDLpubCPKKfIrw4gc+SQkHcaD17s7GI6i/ziWY=.ed25519",
|
||||
"sequence": 2,
|
||||
"timestamp": 1514517078157,
|
||||
"hash": "sha256",
|
||||
"content": {
|
||||
"type": "post",
|
||||
"text": "Second post!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The struct we've implemented ignores the `hash` and `previous` fields while adding others that are necessary for our application (for example, the `read` and `subject` fields).
|
||||
|
||||
Now we can implement a `new()` method for our `Post` struct:
|
||||
|
||||
```rust
|
||||
impl Post {
|
||||
// Create a new instance of the Post struct. A default value of `false` is
|
||||
// set for `read`.
|
||||
pub fn new(
|
||||
key: String,
|
||||
text: String,
|
||||
date: String,
|
||||
sequence: u64,
|
||||
timestamp: i64,
|
||||
subject: Option<String>,
|
||||
) -> Post {
|
||||
Post {
|
||||
key,
|
||||
text,
|
||||
date,
|
||||
sequence,
|
||||
timestamp,
|
||||
subject,
|
||||
read: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create a Stream of Scuttlebutt Messages
|
||||
|
||||
We're going to step away from database concerns for a moment to focus on obtaining Scuttlebutt messages and filtering them. In this section we'll demonstrate the `create_history_stream()` RPC method, one of the foundational methods in Scuttlebutt development, which takes a Scuttlebutt peer ID and returns a stream of messages authored by that peer.
|
||||
|
||||
Let's write a `get_message_stream()` function that will take the key of the Scuttlebutt peer we're interested in, along with a sequence number (this will come in handy later). In the function we'll initialise a connection to the sbot, define the arguments for the RPC call and then make the `create_history_stream()` call - returning the stream to the caller. Calling this function with a `sequence_number` of 0 would return a stream of every message ever authored by the given public key.
|
||||
|
||||
`src/sbot.rs`
|
||||
|
||||
```rust
|
||||
// Return a stream of messages authored by the given public key.
|
||||
//
|
||||
// This returns all messages regardless of type.
|
||||
pub async fn get_message_stream(
|
||||
public_key: &str,
|
||||
sequence_number: u64,
|
||||
) -> impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>> {
|
||||
let mut sbot = init_sbot().await.unwrap();
|
||||
|
||||
let history_stream_args = CreateHistoryStream::new(public_key.to_string())
|
||||
// Define the shape of the returned messages: defining `keys_values`
|
||||
// as `(true, true)` will result in messages being returned as KVTs. KVT
|
||||
// stands for Key Value Timestamp. The Key is the message ID and the Value
|
||||
// contains the actual data of the message (including fields such as
|
||||
// `author`, `previous`, `hash` etc.).
|
||||
.keys_values(true, true)
|
||||
// Define the starting point of the message stream. In other words,
|
||||
// only return messages starting after the given sequence number.
|
||||
.after_seq(sequence_number);
|
||||
|
||||
sbot.create_history_stream(history_stream_args)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
```
|
||||
|
||||
### Filter Post-Type Scuttlebutt Messages
|
||||
|
||||
Now that we have the ability to obtain all messages authored by a specific peer, we need a way to filter those messages and extract only the root posts (our application isn't concerned with replies to posts). The function we'll write to perform this task might appear intimidating but we're simply iterating over a stream of messages, filtering for post-type messages with a `root` field and extracting the data we need for our application. A vector of `Post` is returned, using the struct we defined at the beginning of this installment of the tutorial.
|
||||
|
||||
`src/sbot.rs`
|
||||
|
||||
```rust
|
||||
// Filter a stream of messages and return a vector of root posts.
|
||||
pub async fn get_root_posts(
|
||||
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
|
||||
) -> (u64, Vec<Post>) {
|
||||
let mut latest_sequence = 0;
|
||||
let mut posts = Vec::new();
|
||||
|
||||
futures::pin_mut!(history_stream);
|
||||
|
||||
while let Some(res) = history_stream.next().await {
|
||||
match res {
|
||||
Ok(msg) => {
|
||||
// Filter by content type to only select post-type messages.
|
||||
if msg.value.is_message_type(SsbMessageContentType::Post) {
|
||||
let content = msg.value.content.to_owned();
|
||||
if let Value::Object(content_map) = content {
|
||||
// If the content JSON object contains a key-value pair
|
||||
// with a key of `root` this indicates the message
|
||||
// is a reply to another message. The value of the `root`
|
||||
// key is the message ID of the message being replied to.
|
||||
// In our case, since we only want root posts, we ignore
|
||||
// any message with a `root` field.
|
||||
if !content_map.contains_key("root") {
|
||||
latest_sequence = msg.value.sequence;
|
||||
|
||||
let text = content_map.get_key_value("text").unwrap().1.to_string();
|
||||
let timestamp = msg.value.timestamp.round() as i64 / 1000;
|
||||
let datetime = NaiveDateTime::from_timestamp(timestamp, 0);
|
||||
let date = datetime.format("%d %b %Y").to_string();
|
||||
// Copy the beginning of the post text to serve as the
|
||||
// subject (for display in the UI).
|
||||
let subject = text.get(0..52).map(|s| s.to_string());
|
||||
|
||||
let post = Post::new(
|
||||
msg.key.to_owned(),
|
||||
text,
|
||||
date,
|
||||
msg.value.sequence,
|
||||
timestamp,
|
||||
subject,
|
||||
);
|
||||
|
||||
posts.push(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Print the `GolgiError` of this element to `stderr`.
|
||||
warn!("err: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(latest_sequence, posts)
|
||||
}
|
||||
```
|
||||
|
||||
### Initialise a Database Tree for Posts
|
||||
|
||||
You may recall creating a database tree for peers in the previous tutorial installment. Now we can add a tree to store posts, first by updating our `Database` struct and then by opening the tree on our database instance.
|
||||
|
||||
`src/db.rs`
|
||||
|
||||
```rust
|
||||
// An instance of the key-value database and relevant trees.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
// The sled database instance.
|
||||
db: Db,
|
||||
// A database tree containing Peer struct instances for all the peers
|
||||
// we are subscribed to.
|
||||
peer_tree: Tree,
|
||||
// A database tree containing Post struct instances for all of the posts
|
||||
// we have downloaded from the peer to whom we subscribe.
|
||||
pub post_tree: Tree,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
// Initialise the database by opening the database file, loading the
|
||||
// peers and posts trees and returning an instantiated Database struct.
|
||||
pub fn init(path: &Path) -> Self {
|
||||
// Open the database at the given path.
|
||||
// The database will be created if it does not yet exist.
|
||||
// This code will panic if an IO error is encountered.
|
||||
info!("Initialising sled database");
|
||||
let db = sled::open(path).expect("Failed to open database");
|
||||
debug!("Opening 'peers' database tree");
|
||||
let peer_tree = db
|
||||
.open_tree("peers")
|
||||
.expect("Failed to open 'peers' database tree");
|
||||
debug!("Opening 'posts' database tree");
|
||||
let post_tree = db
|
||||
.open_tree("posts")
|
||||
.expect("Failed to open 'posts' database tree");
|
||||
|
||||
Database {
|
||||
db,
|
||||
peer_tree,
|
||||
post_tree,
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Don't forget to delete the previous instance of the database on file before attempting to compile and execute this code (database migrations are out of the scope of this tutorial, hence the heavy-handed approach). If you're on Linux it'll likely be at `~/.config/lykin/database`.
|
||||
|
||||
### Add a Post to the Database
|
||||
|
||||
The last things we'll tackle in this installment are the methods required to add posts to the database. The process is very similar to the one we employed for adding peers to the database.
|
||||
|
||||
`src/db.rs`
|
||||
|
||||
```rust
|
||||
impl Database {
|
||||
// ...
|
||||
|
||||
// Add a post to the database by inserting an instance of the Post struct
|
||||
// into the post tree. The key of the entry is formed by concatenating
|
||||
// the public key of the peer who authored the post and the key of the
|
||||
// post itself, separated by an underscore. The Post is serialized as
|
||||
// bincode before the database entry is inserted.
|
||||
//
|
||||
// This method can also be used to update an existing database entry.
|
||||
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
|
||||
let post_key = format!("{}_{}", public_key, post.key);
|
||||
debug!("Serializing post data for {} to bincode", &post_key);
|
||||
let post_bytes = bincode::serialize(&post).unwrap();
|
||||
|
||||
debug!("Inserting post {} into 'posts' database tree", &post_key);
|
||||
self.post_tree.insert(post_key.as_bytes(), post_bytes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice the `post_key` variable in the code above: the concatenation of the public key of the peer who authored the post and the post key (aka. message ID or message reference). Here's an example:
|
||||
|
||||
`@HEqy940T6uB+T+d9Jaa58aNfRzLx9eRWqkZljBmnkmk=.ed25519_%AbEupzW67huP6LUNO2CAhkK2RNCeUsmbPAP7rgCi3HY=.sha256`
|
||||
|
||||
Using this approach will allow us to retrieve all the posts by a given public key from our posts database tree by using the public key as the prefix with which to filter entries.
|
||||
|
||||
### Add a Post Batch to the Database
|
||||
|
||||
On most occasions we'll find ourselves in a situation where we wish to add multiple posts by a single author to the database. The sled database we're using has an `apply_batch()` method to apply atomic updates. Let's write an `add_post_batch` for our `Database` implementation:
|
||||
|
||||
`src/db.rs`
|
||||
|
||||
```rust
|
||||
impl Database {
|
||||
// ...
|
||||
|
||||
// Add a batch of posts to the database by inserting a vector of instances
|
||||
// of the Post struct into the post tree. The key of each entry is formed
|
||||
// by concatenating the public key of the peer who authored the post and
|
||||
// the key of the post itself, separated by an underscore. Each Post is
|
||||
// serialized as bincode before the database entry is inserted.
|
||||
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
|
||||
let mut post_batch = Batch::default();
|
||||
|
||||
for post in posts {
|
||||
let post_key = format!("{}_{}", public_key, post.key);
|
||||
debug!("Serializing post data for {} to bincode", &post_key);
|
||||
let post_bytes = bincode::serialize(&post).unwrap();
|
||||
|
||||
debug!("Inserting post {} into 'posts' database tree", &post_key);
|
||||
post_batch.insert(post_key.as_bytes(), post_bytes)
|
||||
}
|
||||
|
||||
debug!("Applying batch insertion into 'posts' database tree");
|
||||
self.post_tree.apply_batch(post_batch)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
|
||||
In this installment we learned how to request a stream of Scuttlebutt messages from an sbot and how to filter those messages by type. We added a `Post` data structure, a database tree to contain posts and methods to add posts to the database. All of this work was done in preparation for the next step in the development of our application: fetching the posts of each peer we subscribe to and storing them in the database whenever a subscription event occurs.
|
||||
|
||||
In the next tutorial installment we'll write an asynchronous task loop to run background processes. We'll put the task loop into action by invoking a post-fetching and filtering task from our subscription route handler, powered by the code we wrote today.
|
||||
|
||||
## Funding
|
||||
|
||||
This work has been funded by a Scuttlebutt Community Grant.
|
6
part_4_posts_streams/notes
Normal file
6
part_4_posts_streams/notes
Normal file
@ -0,0 +1,6 @@
|
||||
[ part 4 ]
|
||||
|
||||
- create basic post struct
|
||||
- get message stream for peer (createHistoryStream)
|
||||
- filter root posts from message stream
|
||||
- add posts to database
|
81
part_4_posts_streams/src/db.rs
Normal file
81
part_4_posts_streams/src/db.rs
Normal file
@ -0,0 +1,81 @@
|
||||
use std::path::Path;
|
||||
|
||||
use log::{debug, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sled::{Db, IVec, Result, Tree};
|
||||
|
||||
/// Scuttlebutt peer data.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Peer {
|
||||
pub public_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
/// Create a new instance of the Peer struct using the given public
|
||||
/// key. A default value is set for name.
|
||||
pub fn new(public_key: &str) -> Peer {
|
||||
Peer {
|
||||
public_key: public_key.to_string(),
|
||||
name: "".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify the name field of an instance of the Peer struct, leaving
|
||||
/// the other values unchanged.
|
||||
pub fn set_name(self, name: &str) -> Peer {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance of the key-value database and relevant trees.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
/// The sled database instance.
|
||||
db: Db,
|
||||
/// A database tree containing Peer struct instances for all the peers
|
||||
/// we are subscribed to.
|
||||
peer_tree: Tree,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Initialise the database by opening the database file, loading the
|
||||
/// peers tree and returning an instantiated Database struct.
|
||||
pub fn init(path: &Path) -> Self {
|
||||
// Open the database at the given path.
|
||||
// The database will be created if it does not yet exist.
|
||||
// This code will panic if an IO error is encountered.
|
||||
info!("Initialising sled database");
|
||||
let db = sled::open(path).expect("Failed to open database");
|
||||
debug!("Opening 'peers' database tree");
|
||||
let peer_tree = db
|
||||
.open_tree("peers")
|
||||
.expect("Failed to open 'peers' database tree");
|
||||
|
||||
Database { db, peer_tree }
|
||||
}
|
||||
|
||||
/// Add a peer to the database by inserting the public key into the peer
|
||||
/// tree.
|
||||
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
|
||||
debug!("Serializing peer data for {} to bincode", &peer.public_key);
|
||||
let peer_bytes = bincode::serialize(&peer).unwrap();
|
||||
|
||||
debug!(
|
||||
"Inserting peer {} into 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
self.peer_tree.insert(&peer.public_key, peer_bytes)
|
||||
}
|
||||
|
||||
/// Remove a peer from the database, as represented by the given public
|
||||
/// key.
|
||||
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
|
||||
debug!("Removing peer {} from 'peers' database tree", &public_key);
|
||||
self.peer_tree.remove(&public_key).map(|_| ())
|
||||
}
|
||||
}
|
25
part_4_posts_streams/src/main.rs
Normal file
25
part_4_posts_streams/src/main.rs
Normal file
@ -0,0 +1,25 @@
|
||||
mod db;
|
||||
mod routes;
|
||||
mod sbot;
|
||||
mod utils;
|
||||
|
||||
use rocket::{launch, routes};
|
||||
use rocket_dyn_templates::Template;
|
||||
use xdg::BaseDirectories;
|
||||
|
||||
use crate::{db::Database, routes::*};
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
// Create the key-value database.
|
||||
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
|
||||
let db_path = xdg_dirs
|
||||
.place_config_file("database")
|
||||
.expect("cannot create database directory");
|
||||
let db = Database::init(&db_path);
|
||||
|
||||
rocket::build()
|
||||
.manage(db)
|
||||
.attach(Template::fairing())
|
||||
.mount("/", routes![home, subscribe_form, unsubscribe_form])
|
||||
}
|
141
part_4_posts_streams/src/routes.rs
Normal file
141
part_4_posts_streams/src/routes.rs
Normal file
@ -0,0 +1,141 @@
|
||||
use log::{info, warn};
|
||||
use rocket::{
|
||||
form::Form,
|
||||
get, post,
|
||||
request::FlashMessage,
|
||||
response::{Flash, Redirect},
|
||||
uri, FromForm, State,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
|
||||
use crate::{
|
||||
db::{Database, Peer},
|
||||
sbot, utils,
|
||||
};
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct PeerForm {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn home(flash: Option<FlashMessage<'_>>) -> Template {
|
||||
let whoami = match sbot::whoami().await {
|
||||
Ok(id) => id,
|
||||
Err(e) => format!("Error making `whoami` RPC call: {}. Please ensure the local go-sbot is running and refresh.", e),
|
||||
};
|
||||
|
||||
Template::render("base", context! { whoami: whoami, flash: flash })
|
||||
}
|
||||
|
||||
#[post("/subscribe", data = "<peer>")]
|
||||
pub async fn subscribe_form(
|
||||
db: &State<Database>,
|
||||
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 name of the peer to which we are subscribing.
|
||||
let peer_name = match sbot::get_name(&peer.public_key).await {
|
||||
Ok(name) => name,
|
||||
Err(e) => {
|
||||
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
|
||||
// Return an empty string if an error occurs.
|
||||
String::from("")
|
||||
}
|
||||
};
|
||||
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
|
||||
|
||||
match sbot::follow_if_not_following(&peer.public_key).await {
|
||||
Ok(_) => {
|
||||
// Add the peer to the database.
|
||||
if db.add_peer(peer_info).is_ok() {
|
||||
info!("Added {} to 'peers' database tree", &peer.public_key);
|
||||
} else {
|
||||
let err_msg = format!(
|
||||
"Failed to add peer {} to 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
warn!("{}", err_msg);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
|
||||
#[post("/unsubscribe", data = "<peer>")]
|
||||
pub async fn unsubscribe_form(
|
||||
db: &State<Database>,
|
||||
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);
|
||||
match sbot::unfollow_if_following(&peer.public_key).await {
|
||||
Ok(_) => {
|
||||
// Remove the peer from the database.
|
||||
if db.remove_peer(&peer.public_key).is_ok() {
|
||||
info!(
|
||||
"Removed peer {} from 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Failed to remove peer {} from 'peers' database tree",
|
||||
&peer.public_key
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{}", e);
|
||||
return Err(Flash::error(Redirect::to(uri!(home)), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
|
||||
/*
|
||||
#[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);
|
||||
sbot::follow_if_not_following(&peer.public_key).await;
|
||||
}
|
||||
|
||||
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);
|
||||
sbot::unfollow_if_following(&peer.public_key).await;
|
||||
}
|
||||
|
||||
Ok(Redirect::to(uri!(home)))
|
||||
}
|
||||
*/
|
130
part_4_posts_streams/src/sbot.rs
Normal file
130
part_4_posts_streams/src/sbot.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use std::env;
|
||||
|
||||
use golgi::{api::friends::RelationshipQuery, sbot::Keystore, Sbot};
|
||||
use log::{info, warn};
|
||||
|
||||
/// Initialise a connection to a Scuttlebutt server.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Return the public key of the local sbot instance.
|
||||
pub async fn whoami() -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.whoami().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Check follow status.
|
||||
///
|
||||
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
|
||||
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())
|
||||
}
|
||||
|
||||
/// Follow a peer.
|
||||
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.follow(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Unfollow a peer.
|
||||
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Return the name (self-identifier) for the peer associated with the given
|
||||
/// public key.
|
||||
///
|
||||
/// The public key of the peer will be returned if a name is not found.
|
||||
pub async fn get_name(public_key: &str) -> Result<String, String> {
|
||||
let mut sbot = init_sbot().await?;
|
||||
|
||||
sbot.get_name(public_key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Check the follow status of a remote peer and follow them if not already
|
||||
/// following.
|
||||
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
|
||||
if let Ok(whoami) = whoami().await {
|
||||
match is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Followed peer {}", &remote_peer);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
},
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!(
|
||||
"Already following peer {}. No further action taken",
|
||||
&remote_peer
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(
|
||||
"Failed to determine follow status: received unrecognised response from local sbot"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the follow status of a remote peer and unfollow them if already
|
||||
/// following.
|
||||
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
|
||||
if let Ok(whoami) = whoami().await {
|
||||
match is_following(&whoami, remote_peer).await {
|
||||
Ok(status) if status.as_str() == "true" => {
|
||||
info!("Unfollowing peer {}", &remote_peer);
|
||||
match unfollow_peer(remote_peer).await {
|
||||
Ok(_) => {
|
||||
info!("Unfollowed peer {}", &remote_peer);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(
|
||||
"Failed to determine follow status: received unrecognised response from local sbot"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
|
||||
warn!("{}", err_msg);
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
32
part_4_posts_streams/src/utils.rs
Normal file
32
part_4_posts_streams/src/utils.rs
Normal file
@ -0,0 +1,32 @@
|
||||
//! Public key validation.
|
||||
|
||||
/// 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("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(())
|
||||
}
|
21
part_4_posts_streams/templates/base.html.tera
Normal file
21
part_4_posts_streams/templates/base.html.tera
Normal file
@ -0,0 +1,21 @@
|
||||
<!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><a href="/">lykin</a></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>
|
||||
{% if flash and flash.kind == "error" %}
|
||||
<p style="color: red;">[ {{ flash.message }} ]</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user