diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c41d15b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lykin" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-std = "1.10" +bincode = "1.3" +env_logger = "0.9" +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"] } +sled = "0.34" diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..8a75ce4 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,84 @@ +// Key-value store using sled. + +use std::{ + fmt, + fmt::{Display, Formatter}, + path::Path, +}; + +use bincode::Options; +use log::{debug, info, warn}; +use sled::{Db, IVec, Result, Tree}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IVecString { + pub bytes: IVec, + pub string: String, +} + +impl Display for IVecString { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.string) + } +} + +impl From for IVecString { + fn from(bytes: IVec) -> Self { + Self { + string: String::from_utf8_lossy(&bytes).into_owned(), + bytes, + } + } +} + +pub struct Database { + /// Stores the sled database instance. + db: Db, + /// Stores the public keys of all the feeds we are subscribed to. + feed_tree: Tree, + /// Stores the messages (content and metadata) for all the feeds we are subscribed to. + message_tree: Tree, +} + +impl Database { + // TODO: return Result and use try operators + // implement simple custom error type + 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 the sled database"); + let db = sled::open(path).expect("failed to open database"); + debug!("opening the 'feeds' database tree"); + let feed_tree = db + .open_tree("feeds") + .expect("failed to open database feeds tree"); + debug!("opening the 'messages' database tree"); + let message_tree = db + .open_tree("messages") + .expect("failed to open database messages tree"); + + Database { + db, + feed_tree, + message_tree, + } + } + + pub fn add_feed(&self, public_key: &str) -> Result> { + self.feed_tree.insert(&public_key, vec![0]) + } + + pub fn remove_feed(&self, public_key: &str) -> Result> { + self.feed_tree.remove(&public_key) + } + + pub fn get_feeds(&self) -> Vec { + self.feed_tree + .iter() + .keys() + .map(|bytes| IVecString::from(bytes.unwrap())) + .map(|ivec_string| ivec_string.string) + .collect() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d75bcd4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,79 @@ +mod db; +mod sbot; +mod utils; + +use std::path::Path; + +use log::{debug, info, warn}; +use rocket::{form::Form, get, launch, post, response::Redirect, routes, uri, FromForm, State}; +use rocket_dyn_templates::{tera::Context, Template}; + +use crate::db::Database; + +#[derive(FromForm)] +struct Peer { + public_key: String, +} + +#[get("/")] +fn index(db: &State) -> Template { + let mut context = Context::new(); + let feeds = db.get_feeds(); + context.insert("feeds", &feeds); + + Template::render("index", &context.into_json()) +} + +#[post("/subscribe", data = "")] +fn subscribe_form(db: &State, peer: Form) -> Redirect { + // validate the public key + if let Ok(_) = utils::validate_public_key(&peer.public_key) { + debug!("public key {} is valid", &peer.public_key); + match db.add_feed(&peer.public_key) { + Ok(_) => { + debug!("added {} to feed tree in database", &peer.public_key); + // check if we already follow the peer + // - if not, follow the peer and create a tree for the peer + } + Err(_e) => warn!( + "failed to add {} to feed tree in database", + &peer.public_key + ), + } + } else { + warn!("{} is invalid", &peer.public_key); + } + + Redirect::to(uri!(index)) +} + +#[post("/unsubscribe", data = "")] +fn unsubscribe_form(db: &State, peer: Form) -> Redirect { + // validate the public key + match utils::validate_public_key(&peer.public_key) { + Ok(_) => { + debug!("public key {} is valid", &peer.public_key); + match db.remove_feed(&peer.public_key) { + Ok(_) => debug!("removed {} from feed tree in database", &peer.public_key), + Err(_e) => warn!( + "failed to remove {} from feed tree in database", + &peer.public_key + ), + } + } + Err(e) => warn!("{} is invalid: {}", &peer.public_key, e), + } + + Redirect::to(uri!(index)) +} + +#[launch] +fn rocket() -> _ { + env_logger::init(); + + info!("launching the web server"); + rocket::build() + .manage(Database::init(Path::new("lykin"))) + .mount("/", routes![index, subscribe_form, unsubscribe_form]) + .attach(Template::fairing()) +} diff --git a/src/sbot.rs b/src/sbot.rs new file mode 100644 index 0000000..dc83b47 --- /dev/null +++ b/src/sbot.rs @@ -0,0 +1,16 @@ +// Scuttlebutt functionality. + +use async_std::task; +use golgi::Sbot; + +/// Follow a peer. +pub fn follow_peer(public_key: &str) -> Result { + task::block_on(async { + let mut sbot_client = Sbot::init(None, None).await.map_err(|e| e.to_string())?; + + match sbot_client.follow(public_key).await { + Ok(_) => Ok("Followed peer".to_string()), + Err(e) => Err(format!("Failed to follow peer: {}", e)), + } + }) +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f891dd9 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,30 @@ +/// 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 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]; + + // length of a base64 encoded ed25519 public key + if base64_str.len() != 44 { + return Err("base64 data length is incorrect".to_string()); + } + + Ok(()) +} diff --git a/templates/index.html.tera b/templates/index.html.tera new file mode 100644 index 0000000..76aa1c1 --- /dev/null +++ b/templates/index.html.tera @@ -0,0 +1,32 @@ + + + + + + lykin + + + + + +

lykin

+
+
+ Subscription Management +
+
+
+ + +
+
+
+

Subscriptions

+
    + {% for feed in feeds -%} +
  • {{ feed }}
  • + {%- endfor %} +
+
+ +