add part 9 draft

This commit is contained in:
glyph 2022-09-09 10:48:45 +01:00
parent 4bb354f239
commit 1ad255325b
19 changed files with 1650 additions and 0 deletions

View File

@ -0,0 +1,20 @@
[package]
name = "part_9_read_delete"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = "1.10"
bincode = "1.3"
chrono = "0.4"
futures = "0.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"
serde_json = "1"
sled = "0.34"
xdg = "2.4.1"

View File

@ -0,0 +1,408 @@
# lykin tutorial
## Part 9: Read, Unread and Delete
### Introduction
In the last installment we implemented the functionality necessary to display posts in the web interface of our application, bringing it close to completion. Today we're going to add the finishing touches. We'll count and display the number of unread posts for each peer we subscribe to and add the ability to mark a post as read or unread. To finish things off, we'll allow the user to delete individual posts via the web interface. This installment will touch the database, route handlers and templates. Let's get started!
### Outline
- Count unread posts
- Display unread post count
- Mark a post as read
- Mark a post as unread
- Remove a post from the database
- Update navigation template
### Count Unread Posts
We'll begin by implementing a database method that takes a public key as input and returns the total number of unread posts authored by that peer in our key-value store. The logic is fairly simple: retrieve all posts by the peer, iterate through them and increment a counter every time an unread post is encountered.
`src/db.rs`
```rust
impl Database {
// ...
// Sum the total number of unread posts for the peer represented by the
// given public key.
pub fn get_unread_post_count(&self, public_key: &str) -> u16 {
debug!(
"Counting total number of unread posts for peer {}",
&public_key
);
let mut unread_post_counter = 0;
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
let deserialized_post: Post = bincode::deserialize(&post.1).unwrap();
if !deserialized_post.read {
unread_post_counter += 1
}
});
unread_post_counter
}
}
```
### Display Unread Post Count
Now we can update the peer list of our web application to show the number of unread posts next to the name of each peer. This behaviour is similar to what you might see in an email client, where the number of unread messages is displayed alongside the name of each folder in your mailbox.
First we need to update the `home`, `posts` and `post` route handlers of our application to retrieve the unread post counts and pass them into the template as context variables.
`src/routes.rs`
```rust
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
// Count the total unread posts for the given peer.
let unread_count = db.get_unread_post_count(&peer.public_key);
// Push a tuple of the peer data and peer unread post count
// to the `peers_unread` vector.
peers_unread.push((peer, unread_count.to_string()));
}
Template::render("base", context! { peers: &peers_unread, flash: flash })
}
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
let context = context! {
selected_peer: &public_key,
peers: &peers_unread,
posts: &posts
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers_unread,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post
};
Template::render("base", context)
}
```
You'll notice that the main change in the code above, when compared to the code from previous installments, is the `peers_unread` vector and populating loop:
```rust
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
```
The other difference is that we now pass `context! { peers: &peers_unread, ... }` instead of `context! { peers: &peers, ... }`.
Now we need to update the peers list template to utilise the newly-provided unread post count data.
`templates/peer_list.html.tera`
```html
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.0.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.0.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.0.name %}
{{ peer.0.name }}
{% else %}
{{ peer.0.public_key }}
{% endif %}
</code>
{% if peer.1 != "0" %}<p>{{ peer.1 }}</p>{% endif %}
</a>
</li>
{%- endfor %}
</ul>
</div>
```
Since the `peers` context variable is a tuple of `(peer, unread_post_count)`, we use tuple indexing when referencing the values (ie. `peer.0` for the `peer` data and `peer.1` for the `unread_post_count` data).
Run the application (`cargo run`) and you should now see the unread post count displayed next to the name of each peer in the peers list.
### Mark a Post as Read
Much like an email client, we want to be able to mark individual posts as either `read` or `unread`. We already have icons in place in our `topbar.html.tera` template with which to perform these actions. Now we need to write a route handler to mark a particular post as read.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>/read")]
pub async fn mark_post_read(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Retrieve the post from the database using the public key and msg_id
// from the URL.
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
// Mark the post as read.
post.read = true;
// Reinsert the modified post into the database.
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
```
### Mark a Post as Unread
We can now write the equivalent route handler for marking a post as unread.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>/unread")]
pub async fn mark_post_unread(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
post.read = false;
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
```
We still need to mount these routes to our Rocket application in `src/main.rs` and update the logic in our navigation template to wrap the `read` and `unread` icons in anchor elements with the correct URLs. We'll take care of that once we've written the backend code to remove a post from the database.
### Remove a Post From the Database
We already have a `remove_peer()` method in our database which is used when we unsubscribe from a peer. Now we'll write the equivalent method for a post.
`src/db.rs`
```rust
impl Database {
// ...
// Remove a single post from the post tree, authored by the given public
// key and defined by the given message ID.
pub fn remove_post(&self, public_key: &str, msg_id: &str) -> Result<()> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!("Removing post {} from 'posts' database tree", &post_key);
// .remove() would ordinarily return the value of the deleted entry
// as an Option, returning None if the post_key was not found.
// We don't care about the value of the deleted entry so we simply
// map the Option to ().
self.post_tree.remove(post_key.as_bytes()).map(|_| ())
}
}
```
Now we need to write a route handler to respond to a delete request. Much like the route handlers for marking a post as read and unread, we include the public key of the peer and the message ID of the post in the URL. We then use those values when invoking the `remove_post()` method.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>/delete")]
pub async fn delete_post(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Delete the post from the database.
match db.remove_post(public_key, msg_id) {
Ok(_) => info!(
"Removed post {} by {} from 'posts' database tree",
msg_id, public_key
),
Err(e) => warn!(
"Failed to remove post {} by {} from 'posts' database tree: {}",
msg_id, public_key, e
),
}
Redirect::to(uri!(posts(public_key)))
}
```
The three routes we've created so far can now be mounted to the Rocket application.
`src/main.rs`
```rust
#[launch]
async fn rocket() -> _ {
// ...
rocket::build()
.manage(db)
.manage(tx)
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts,
mark_post_read,
mark_post_unread,
delete_post
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(Template::fairing())
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}
```
### Update Navigation Template
With the backend functionality in place, we can now update the navigation template to ensure the correct URLs are set for the 'mark as read, 'mark as unread' and 'delete post' elements. We will only enable those elements when a post is selected, signalling to the user when action is possible.
`templates/topbar.html.tera`
```html
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
{% if post_is_selected %}
{% set selected_peer_encoded = selected_peer | urlencode_strict %}
{% if post.read %}
{% set mark_unread_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/unread" %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a href={{ mark_unread_url }} class="icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% else %}
{% set mark_read_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/read" %}
<a href={{ mark_read_url }} class="icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% endif %}
{% set delete_post_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/delete" %}
<a href={{ delete_post_url }} class="icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% else %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% endif %}
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
{% if selected_peer %}
<input type="text" id="public_key" name="public_key" maxlength=53 value={{ selected_peer }}>
{% else %}
<input type="text" id="public_key" name="public_key" maxlength=53>
{% endif %}
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>
```
Now we have one more template-related change to make. We need to check the read / unread value of each post in the post list and render the text in bold if it is unread.
`templates/post_list.html.tera`
```html
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container"{% if not post.read %} style="font-weight: bold;"{% endif %} href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>
```
Notice the `{% if not post.read %}` syntax in the code above; that is where we selectively bold the line item for unread posts.
Everything should now be in place. Run the application (`cargo run`) and see how the user interface changes as you mark posts as read / unread and delete posts.
### Conclusion
We did it! We wrote a Scuttlebutt client application in Rust. Congratulations on making it this far in the tutorial! I really hope you've learned something through this experience and that you're feeling inspired to write your own applications or modify this one.
In the next installment of the series I'll share some ideas for improving and extending the application.
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -0,0 +1,303 @@
use std::path::Path;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sled::{Batch, Db, IVec, Result, Tree};
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
pub latest_sequence: u64,
}
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(),
latest_sequence: 0,
}
}
/// 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
}
}
/// Modify the latest_sequence field of an instance of the Peer struct,
/// leaving the other values unchanged.
pub fn set_latest_sequence(self, latest_sequence: u64) -> Peer {
Self {
latest_sequence,
..self
}
}
}
/// 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>,
}
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,
}
}
}
/// 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 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");
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,
}
}
/// 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)
}
/// Get a single peer from the peer tree, defined by the given public key.
/// The byte value for the matching entry, if found, is deserialized from
/// bincode into an instance of the Peer struct.
pub fn get_peer(&self, public_key: &str) -> Result<Option<Peer>> {
debug!(
"Retrieving peer data for {} from 'peers' database tree",
&public_key
);
let peer = self
.peer_tree
.get(public_key.as_bytes())
.unwrap()
.map(|peer| {
debug!("Deserializing peer data for {} from bincode", &public_key);
bincode::deserialize(&peer).unwrap()
});
Ok(peer)
}
/// Get a list of all peers in the peer tree. The byte value for each
/// peer entry is deserialized from bincode into an instance of the Peer
/// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
/// 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(|_| ())
}
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
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)
}
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
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)
}
/// Get a list of all posts in the post tree authored by the given public
/// key and sort them by timestamp in descending order. The byte value for
/// each matching entry is deserialized from bincode into an instance of
/// the Post struct.
pub fn get_posts(&self, public_key: &str) -> Result<Vec<Post>> {
debug!("Retrieving data for all posts in the 'posts' database tree");
let mut posts = Vec::new();
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
posts.push(bincode::deserialize(&post.1).unwrap())
});
posts.sort_by(|a: &Post, b: &Post| b.timestamp.cmp(&a.timestamp));
Ok(posts)
}
/// Get a single post from the post tree, authored by the given public key
/// and defined by the given message ID. The byte value for the matching
/// entry, if found, is deserialized from bincode into an instance of the
/// Post struct.
pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result<Option<Post>> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!(
"Retrieving post data for {} from 'posts' database tree",
&post_key
);
let post = self
.post_tree
.get(post_key.as_bytes())
.unwrap()
.map(|post| {
debug!("Deserializing post data for {} from bincode", &post_key);
bincode::deserialize(&post).unwrap()
});
Ok(post)
}
/// Remove a single post from the post tree, authored by the given public
/// key and defined by the given message ID.
pub fn remove_post(&self, public_key: &str, msg_id: &str) -> Result<()> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!("Removing post {} from 'posts' database tree", &post_key);
// .remove() would ordinarily return the value of the deleted entry
// as an Option, returning None if the post_key was not found.
// We don't care about the value of the deleted entry so we simply
// map the Option to ().
self.post_tree.remove(post_key.as_bytes()).map(|_| ())
}
/// Sum the total number of unread posts for the peer represented by the
/// given public key.
pub fn get_unread_post_count(&self, public_key: &str) -> u16 {
debug!(
"Counting total number of unread posts for peer {}",
&public_key
);
let mut unread_post_counter = 0;
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
let deserialized_post: Post = bincode::deserialize(&post.1).unwrap();
if !deserialized_post.read {
unread_post_counter += 1
}
});
unread_post_counter
}
}

View File

@ -0,0 +1,61 @@
mod db;
mod routes;
mod sbot;
mod task_loop;
mod utils;
use async_std::channel;
use log::info;
use rocket::{
fairing::AdHoc,
fs::{relative, FileServer},
launch, routes,
};
use rocket_dyn_templates::Template;
use xdg::BaseDirectories;
use crate::{db::Database, routes::*, task_loop::Task};
#[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);
let db_clone = db.clone();
// Create a message passing channel.
let (tx, rx) = channel::unbounded();
let tx_clone = tx.clone();
// Spawn the task loop, passing in the receiver half of the channel.
info!("Spawning task loop");
task_loop::spawn(db_clone, rx).await;
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts,
mark_post_read,
mark_post_unread,
delete_post
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}

View File

@ -0,0 +1,242 @@
use async_std::channel::Sender;
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,
task_loop::Task,
utils,
};
#[derive(FromForm)]
pub struct PeerForm {
pub public_key: String,
}
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
Template::render("base", context! { peers: &peers_unread, flash: flash })
}
#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(
db: &State<Database>,
tx: &State<Sender<Task>>,
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);
let peer_id = peer.public_key.to_string();
// Fetch all root posts authored by the peer we're subscribing
// to. Posts will be added to the key-value database.
if let Err(e) = tx.send(Task::FetchAllPosts(peer_id)).await {
warn!("Task loop error: {}", e)
}
} 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)))
}
#[get("/posts/download_latest")]
pub async fn download_latest_posts(db: &State<Database>, tx: &State<Sender<Task>>) -> Redirect {
for peer in db.get_peers() {
// Fetch the latest root posts authored by each peer we're
// subscribed to. Posts will be added to the key-value database.
if let Err(e) = tx
.send(Task::FetchLatestPosts(peer.public_key.clone()))
.await
{
warn!("Task loop error: {}", e)
}
// Fetch the latest name for each peer we're subscribed to and update
// the database.
if let Err(e) = tx.send(Task::FetchLatestName(peer.public_key)).await {
warn!("Task loop error: {}", e)
}
}
Redirect::to(uri!(home))
}
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
// Define context data to be rendered in the template.
let context = context! {
selected_peer: &public_key,
peers: &peers_unread,
posts: &posts
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers_unread,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post,
post_is_selected: &true
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>/read")]
pub async fn mark_post_read(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Retrieve the post from the database using the public key and msg_id
// from the URL.
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
// Mark the post as read.
post.read = true;
// Reinsert the modified post into the database.
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
#[get("/posts/<public_key>/<msg_id>/unread")]
pub async fn mark_post_unread(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
post.read = false;
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
#[get("/posts/<public_key>/<msg_id>/delete")]
pub async fn delete_post(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Delete the post from the database.
match db.remove_post(public_key, msg_id) {
Ok(_) => info!(
"Removed post {} by {} from 'posts' database tree",
msg_id, public_key
),
Err(e) => warn!(
"Failed to remove post {} by {} from 'posts' database tree: {}",
msg_id, public_key, e
),
}
Redirect::to(uri!(posts(public_key)))
}

View File

@ -0,0 +1,213 @@
use std::env;
use async_std::stream::StreamExt;
use chrono::NaiveDateTime;
use golgi::{
api::{friends::RelationshipQuery, history_stream::CreateHistoryStream},
messages::{SsbMessageContentType, SsbMessageKVT},
sbot::Keystore,
GolgiError, Sbot,
};
use log::{info, warn};
use serde_json::value::Value;
use crate::db::Post;
/// 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)
}
}
/// 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())
.keys_values(true, true)
.after_seq(sequence_number);
sbot.create_history_stream(history_stream_args)
.await
.unwrap()
}
/// Filter a stream of messages and return a vector of root posts.
///
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
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) => {
if msg.value.is_message_type(SsbMessageContentType::Post) {
let content = msg.value.content.to_owned();
if let Value::Object(content_map) = content {
if !content_map.contains_key("root") {
latest_sequence = msg.value.sequence;
let text = match content_map.get_key_value("text") {
Some(value) => value.1.to_string(),
None => String::from(""),
};
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();
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)
}

View File

@ -0,0 +1,96 @@
use async_std::{channel::Receiver, task};
use log::{info, warn};
use crate::{sbot, Database};
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
// Update the value of the latest sequence number for
// the peer (this is stored in the database).
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
db.add_peer(peer.set_latest_sequence(latest_sequence))
.unwrap();
}
}
/// Request the name of the peer represented by the given public key (ID)
/// and update the existing entry in the database.
async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
match sbot::get_name(&peer_id).await {
Ok(name) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
let updated_peer = peer.set_name(&name);
match db.add_peer(updated_peer) {
Ok(_) => info!("Updated name for peer: {}", &peer_id),
Err(e) => {
warn!("Failed to update name for peer: {}: {}", &peer_id, e)
}
}
}
}
Err(e) => warn!("Failed to fetch name for {}: {}", &peer_id, e),
}
}
pub enum Task {
Cancel,
FetchAllPosts(String),
FetchLatestPosts(String),
FetchLatestName(String),
}
/// Spawn an asynchronous loop which receives tasks over an unbounded channel
/// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch all messages authored by the given peer, filter
// the root posts and insert them into the posts tree of the
// database.
Task::FetchAllPosts(peer_id) => {
info!("Fetching all posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, 0).await;
}
// Fetch only the latest messages authored by the given peer,
// ie. messages with sequence numbers greater than those
// which are already stored in the database.
//
// Retrieve the root posts from those messages and insert them
// into the posts tree of the database.
Task::FetchLatestPosts(peer_id) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
info!("Fetching latest posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, peer.latest_sequence).await;
}
}
// Fetch the latest name for the given peer and update the
// peer entry in the peers tree of the database.
Task::FetchLatestName(peer_id) => {
info!("Fetching latest name for peer: {}", peer_id);
fetch_name_and_update_db(&db, peer_id).await;
}
// Break out of the task loop.
Task::Cancel => {
info!("Exiting task loop...");
break;
}
}
}
});
}

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,153 @@
.content {
background-color: lightyellow;
border: 5px solid #ffd700;
border-radius: 1rem;
grid-area: content;
padding: 1.5rem;
overflow-y: scroll;
word-wrap: anywhere;
}
.container {
height: 100%;
width: 100%;
margin: 0;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.flash-message {
margin-left: auto;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
color: red;
}
.flex-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flex-container > input {
margin: 0.3rem;
}
.grid-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr 3fr;
grid-template-areas: 'nav' 'peers' 'posts' 'content';
grid-gap: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.2rem;
overflow: hidden;
height: 85vh;
}
@media only screen and (min-width: 600px) {
.grid-container {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 3fr 4fr;
grid-template-areas:
'nav nav nav nav nav'
'peers posts posts posts posts'
'peers content content content content';
}
}
.icon {
margin-left: 1rem;
}
.nav {
background-color: lightgreen;
border: 5px solid #19a974;
border-radius: 1rem;
grid-area: nav;
padding: 1rem;
}
.peers {
background-color: lightblue;
border: 5px solid #357edd;
border-radius: 1rem;
grid-area: peers;
text-align: left;
}
.peers > ul {
padding-left: 1rem;
}
.peers > ul > li > a {
justify-content: space-between;
}
.peers > ul > li > a > p {
margin: 0;
font-weight: bold;
padding-right: 1rem;
}
.post > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts {
background-color: bisque;
border: 5px solid #ff6300;
border-radius: 1rem;
grid-area: posts;
overflow-y: scroll;
}
.posts > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts > ul > li > a {
justify-content: space-between;
}
.posts > ul > li > a > p {
margin: 0;
}
.selected {
background-color: #f9c587;
}
a {
text-decoration: none;
color: black;
}
code {
word-wrap: anywhere;
}
form {
margin-left: auto;
margin-right: 0.5rem;
}
h1 {
margin-left: 1rem;
}
img {
width: 3rem;
}
li {
list-style: none;
font-size: 12px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<a href="https://www.flaticon.com/free-icons/download" title="download icons">Download icons created by Kiranshastry - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>

View File

@ -0,0 +1,18 @@
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.0.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.0.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.0.name %}
{{ peer.0.name }}
{% else %}
{{ peer.0.public_key }}
{% endif %}
</code>
{% if peer.1 != "0" %}<p>{{ peer.1 }}</p>{% endif %}
</a>
</li>
{%- endfor %}
</ul>
</div>

View File

@ -0,0 +1,5 @@
<div class="content">
{% if post %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') | trim }}
{% endif %}
</div>

View File

@ -0,0 +1,20 @@
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container"{% if not post.read %} style="font-weight: bold;"{% endif %} href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,55 @@
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
{% if post_is_selected %}
{% set selected_peer_encoded = selected_peer | urlencode_strict %}
{% set selected_post_encoded = selected_post | urlencode_strict %}
{% if post.read %}
{% set mark_unread_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/unread" %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a href={{ mark_unread_url }} class="icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% else %}
{% set mark_read_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/read" %}
<a href={{ mark_read_url }} class="icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% endif %}
{% set delete_post_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/delete" %}
<a href={{ delete_post_url }} class="icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% else %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% endif %}
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
{% if selected_peer %}
<input type="text" id="public_key" name="public_key" maxlength=53 value={{ selected_peer }}>
{% else %}
<input type="text" id="public_key" name="public_key" maxlength=53>
{% endif %}
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>