add part 3 draft tutorial

This commit is contained in:
glyph 2022-08-23 11:44:16 +01:00
parent f4bb67fc7f
commit dee99dde2a
12 changed files with 1120 additions and 4 deletions

145
Cargo.lock generated
View File

@ -297,6 +297,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -341,6 +350,12 @@ version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3"
[[package]]
name = "byteorder"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]]
name = "bytes"
version = "1.2.1"
@ -462,6 +477,29 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
dependencies = [
"cfg-if 1.0.0",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "045ebe27666471bb549370b4b0b3e51b07f56325befa4284db65fc89c02511b1"
dependencies = [
"autocfg",
"cfg-if 1.0.0",
"crossbeam-utils",
"memoffset",
"once_cell",
"scopeguard",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.11"
@ -561,6 +599,15 @@ dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
@ -634,6 +681,16 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi 0.3.9",
]
[[package]]
name = "fsevent"
version = "0.4.0"
@ -773,6 +830,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "gcc"
version = "0.3.55"
@ -1171,7 +1237,7 @@ dependencies = [
"async-std",
"async-stream 0.2.1",
"base64 0.11.0",
"dirs",
"dirs 2.0.2",
"futures",
"get_if_addrs",
"hex",
@ -1274,6 +1340,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "memoffset"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.16"
@ -1449,6 +1524,17 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
[[package]]
name = "parking_lot"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core 0.8.5",
]
[[package]]
name = "parking_lot"
version = "0.12.1"
@ -1456,7 +1542,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
"lock_api",
"parking_lot_core",
"parking_lot_core 0.9.3",
]
[[package]]
name = "parking_lot_core"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216"
dependencies = [
"cfg-if 1.0.0",
"instant",
"libc",
"redox_syscall",
"smallvec",
"winapi 0.3.9",
]
[[package]]
@ -1499,6 +1599,20 @@ dependencies = [
"rocket_dyn_templates",
]
[[package]]
name = "part_3_database_follows"
version = "0.1.0"
dependencies = [
"bincode",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"sled",
"xdg",
]
[[package]]
name = "pear"
version = "0.2.3"
@ -1816,7 +1930,7 @@ dependencies = [
"memchr",
"multer",
"num_cpus",
"parking_lot",
"parking_lot 0.12.1",
"pin-project-lite",
"rand",
"ref-cast",
@ -2020,6 +2134,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "sled"
version = "0.34.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
dependencies = [
"crc32fast",
"crossbeam-epoch",
"crossbeam-utils",
"fs2",
"fxhash",
"libc",
"log",
"parking_lot 0.11.2",
]
[[package]]
name = "slug"
version = "0.1.4"
@ -2690,6 +2820,15 @@ dependencies = [
"winapi-build",
]
[[package]]
name = "xdg"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4583db5cbd4c4c0303df2d15af80f0539db703fa1c68802d4cbbd2dd0f88f6"
dependencies = [
"dirs 4.0.0",
]
[[package]]
name = "yansi"
version = "0.5.1"

View File

@ -2,5 +2,6 @@
members = [
"part_1_sbot_rocket",
"part_2_subscribe_form"
"part_2_subscribe_form",
"part_3_database_follows"
]

1
part_3_database_follows/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

View 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]
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"] }
bincode = "1.3"
serde = "1"
sled = "0.34"
xdg = "2.4.1"

View 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. Default values are set for latest_sequence and 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 our peer subscription flows.
### Follow / Unfollow a Peer
Let's open our `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 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 function look a quite messy 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 message authored by a peer, as well as how to filter the post-type messages and add them to our key-value database.
## Funding
This work has been funded by a Scuttlebutt Community Grant.

View 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

View 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. Default values are set for latest_sequence and 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(|_| ())
}
}

View 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])
}

View 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)))
}
*/

View 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)
}
}

View 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(())
}

View 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>