From 5012910c25d9dd4ab2af2a09b22a04afc5a6f88a Mon Sep 17 00:00:00 2001 From: glyph Date: Mon, 16 May 2022 15:32:58 +0200 Subject: [PATCH] create ui and task loop --- Cargo.toml | 7 +- src/db.rs | 102 ++++++++++++++++++------- src/main.rs | 131 +++++++++++++++++++++++++++------ src/sbot.rs | 107 ++++++++++++++++++++++++--- src/task_loop.rs | 36 +++++++++ static/icons/delete_post.png | Bin 0 -> 3961 bytes static/icons/download.png | Bin 0 -> 3395 bytes static/icons/icon_attributions | 1 + static/icons/read_post.png | Bin 0 -> 4685 bytes static/icons/unread_post.png | Bin 0 -> 4649 bytes templates/home.html.tera | 131 +++++++++++++++++++++++++++++++++ 11 files changed, 456 insertions(+), 59 deletions(-) create mode 100644 src/task_loop.rs create mode 100644 static/icons/delete_post.png create mode 100644 static/icons/download.png create mode 100644 static/icons/icon_attributions create mode 100644 static/icons/read_post.png create mode 100644 static/icons/unread_post.png create mode 100644 templates/home.html.tera diff --git a/Cargo.toml b/Cargo.toml index c41d15b..45203ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,14 @@ edition = "2021" [dependencies] async-std = "1.10" bincode = "1.3" +chrono = "0.4" env_logger = "0.9" -golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" } +futures = "0.3" +#golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" } +golgi = { path = "../golgi" } 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" diff --git a/src/db.rs b/src/db.rs index 8a75ce4..54c2e05 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,9 +6,19 @@ use std::{ path::Path, }; -use bincode::Options; -use log::{debug, info, warn}; -use sled::{Db, IVec, Result, Tree}; +use log::{debug, info}; +use serde::{Deserialize, Serialize}; +use sled::{Batch, Db, IVec, Result, Tree}; + +// The text and metadata of a Scuttlebutt root post. +#[derive(Debug, Deserialize, Serialize)] +pub struct Post { + pub key: String, + pub text: String, + pub date: String, + pub sequence: u64, + pub read: bool, +} #[derive(Debug, Clone, PartialEq, Eq)] struct IVecString { @@ -31,54 +41,96 @@ impl From for IVecString { } } +#[derive(Clone)] 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, + /// Stores the public keys of all the peers we are subscribed to. + peer_tree: Tree, + /// Stores the posts (content and metadata) for all the feeds we are subscribed to. + pub post_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"); + debug!("opening the 'peers' database tree"); + let peer_tree = db + .open_tree("peers") + .expect("failed to open database peers tree"); + debug!("opening the 'posts' database tree"); + let post_tree = db + .open_tree("posts") + .expect("failed to open database posts tree"); Database { db, - feed_tree, - message_tree, + peer_tree, + post_tree, } } - pub fn add_feed(&self, public_key: &str) -> Result> { - self.feed_tree.insert(&public_key, vec![0]) + pub fn add_peer(&self, public_key: &str) -> Result> { + self.peer_tree.insert(&public_key, vec![0]) } - pub fn remove_feed(&self, public_key: &str) -> Result> { - self.feed_tree.remove(&public_key) + pub fn remove_peer(&self, public_key: &str) -> Result> { + self.peer_tree.remove(&public_key) } - pub fn get_feeds(&self) -> Vec { - self.feed_tree + pub fn get_peers(&self) -> Vec { + self.peer_tree .iter() .keys() .map(|bytes| IVecString::from(bytes.unwrap())) .map(|ivec_string| ivec_string.string) .collect() } + + pub fn insert_post(&self, public_key: &str, post: Post) -> Result> { + let post_key = format!("{}_{}", public_key, post.key); + let post_bytes = bincode::serialize(&post).unwrap(); + + self.post_tree.insert(post_key.as_bytes(), post_bytes) + } + + pub fn insert_post_batch(&self, public_key: &str, posts: Vec) -> Result<()> { + let mut post_batch = Batch::default(); + + for post in posts { + let post_key = format!("{}_{}", public_key, post.key); + let post_bytes = bincode::serialize(&post).unwrap(); + + post_batch.insert(post_key.as_bytes(), post_bytes) + } + + self.post_tree.apply_batch(post_batch) + } + + pub fn get_posts(&self, public_key: &str) -> Result> { + let mut posts = Vec::new(); + + self.post_tree + .scan_prefix(public_key.as_bytes()) + .map(|post| post.unwrap()) + .for_each(|post| posts.push(bincode::deserialize(&post.1).unwrap())); + + Ok(posts) + } + + pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result> { + let post_key = format!("{}_{}", public_key, msg_id); + + let post = self + .post_tree + .get(post_key.as_bytes()) + .unwrap() + .map(|post| bincode::deserialize(&post).unwrap()); + + Ok(post) + } } diff --git a/src/main.rs b/src/main.rs index d75bcd4..6f65f33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,23 @@ mod db; mod sbot; +mod task_loop; mod utils; -use std::path::Path; +use std::{env, path::Path}; +use async_std::{channel, channel::Sender}; use log::{debug, info, warn}; -use rocket::{form::Form, get, launch, post, response::Redirect, routes, uri, FromForm, State}; +use rocket::{ + fairing::AdHoc, + form::Form, + fs::{relative, FileServer}, + get, launch, post, + response::Redirect, + routes, uri, FromForm, State, +}; use rocket_dyn_templates::{tera::Context, Template}; -use crate::db::Database; +use crate::{db::Database, task_loop::Task}; #[derive(FromForm)] struct Peer { @@ -16,27 +25,77 @@ struct Peer { } #[get("/")] -fn index(db: &State) -> Template { - let mut context = Context::new(); - let feeds = db.get_feeds(); - context.insert("feeds", &feeds); +async fn home(db: &State) -> Template { + let peers = db.get_peers(); - Template::render("index", &context.into_json()) + let mut context = Context::new(); + context.insert("peers", &peers); + + Template::render("home", &context.into_json()) +} + +#[get("/posts/")] +async fn posts(db: &State, public_key: &str) -> Template { + let peers = db.get_peers(); + let posts = db.get_posts(public_key).unwrap(); + + let mut context = Context::new(); + context.insert("selected_peer", &public_key); + context.insert("peers", &peers); + context.insert("posts", &posts); + + Template::render("home", &context.into_json()) +} + +#[get("/posts//")] +async fn post(db: &State, public_key: &str, msg_id: &str) -> Template { + let peers = db.get_peers(); + let posts = db.get_posts(public_key).unwrap(); + let post = db.get_post(public_key, msg_id).unwrap(); + + let mut context = Context::new(); + context.insert("selected_peer", &public_key); + context.insert("selected_post", &msg_id); + context.insert("peers", &peers); + context.insert("posts", &posts); + context.insert("post", &post); + + Template::render("home", &context.into_json()) } #[post("/subscribe", data = "")] -fn subscribe_form(db: &State, peer: Form) -> Redirect { - // validate the public key +async fn subscribe_form( + db: &State, + whoami: &State, + tx: &State>, + peer: Form, +) -> Redirect { 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) { + match db.add_peer(&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 + debug!("added {} to peer tree in database", &peer.public_key); + // TODO: i don't think we actually want to follow... + // we might still have the data in our ssb db, even if we don't follow + match sbot::is_following(&whoami.public_key, &peer.public_key).await { + Ok(status) if status.as_str() == "false" => { + match sbot::follow_peer(&peer.public_key).await { + Ok(_) => debug!("followed {}", &peer.public_key), + Err(e) => warn!("failed to follow {}: {}", &peer.public_key, e), + } + } + Ok(status) if status.as_str() == "true" => { + debug!("we already follow {}", &peer.public_key) + } + _ => (), + } + let peer = peer.public_key.to_string(); + if let Err(e) = tx.send(Task::FetchAll(peer)).await { + warn!("task loop error: {}", e) + } } Err(_e) => warn!( - "failed to add {} to feed tree in database", + "failed to add {} to peer tree in database", &peer.public_key ), } @@ -44,7 +103,7 @@ fn subscribe_form(db: &State, peer: Form) -> Redirect { warn!("{} is invalid", &peer.public_key); } - Redirect::to(uri!(index)) + Redirect::to(uri!(home)) } #[post("/unsubscribe", data = "")] @@ -53,10 +112,10 @@ fn unsubscribe_form(db: &State, peer: Form) -> Redirect { 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), + match db.remove_peer(&peer.public_key) { + Ok(_) => debug!("removed {} from peer tree in database", &peer.public_key), Err(_e) => warn!( - "failed to remove {} from feed tree in database", + "failed to remove {} from peer tree in database", &peer.public_key ), } @@ -64,16 +123,42 @@ fn unsubscribe_form(db: &State, peer: Form) -> Redirect { Err(e) => warn!("{} is invalid: {}", &peer.public_key, e), } - Redirect::to(uri!(index)) + Redirect::to(uri!(home)) +} + +struct WhoAmI { + public_key: String, } #[launch] -fn rocket() -> _ { +async fn rocket() -> _ { env_logger::init(); + let public_key: String = sbot::whoami().await.expect("whoami sbot call failed"); + let whoami = WhoAmI { public_key }; + + let db = Database::init(Path::new("lykin_db")); + let db_clone = db.clone(); + + let (tx, rx) = channel::unbounded(); + let tx_clone = tx.clone(); + + task_loop::spawn(rx, db_clone).await; + info!("launching the web server"); rocket::build() - .manage(Database::init(Path::new("lykin"))) - .mount("/", routes![index, subscribe_form, unsubscribe_form]) + .manage(db) + .manage(whoami) + .manage(tx) + .mount( + "/", + routes![home, subscribe_form, unsubscribe_form, posts, 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; + }) + })) } diff --git a/src/sbot.rs b/src/sbot.rs index dc83b47..a82ec6a 100644 --- a/src/sbot.rs +++ b/src/sbot.rs @@ -1,16 +1,103 @@ // Scuttlebutt functionality. -use async_std::task; -use golgi::Sbot; +use async_std::stream::StreamExt; +use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc}; +use golgi::{ + api::friends::RelationshipQuery, + messages::{SsbMessageContentType, SsbMessageKVT}, + sbot::Keystore, + GolgiError, Sbot, +}; +use log::warn; +use serde_json::value::Value; + +use crate::db::Post; + +pub async fn whoami() -> Result { + let mut sbot = Sbot::init(Keystore::Patchwork, None, None) + .await + .map_err(|e| e.to_string())?; + + sbot.whoami().await.map_err(|e| e.to_string()) +} /// 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())?; +pub async fn follow_peer(public_key: &str) -> Result { + let mut sbot = Sbot::init(Keystore::Patchwork, 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)), - } - }) + sbot.follow(public_key).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 { + let mut sbot = Sbot::init(Keystore::Patchwork, None, None) + .await + .map_err(|e| e.to_string())?; + + 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()) +} + +pub async fn get_message_stream( + public_key: &str, +) -> impl futures::Stream> { + let mut sbot = Sbot::init(Keystore::Patchwork, None, None) + .await + .map_err(|e| e.to_string()) + .unwrap(); + + sbot.create_history_stream(public_key.to_string()) + .await + .unwrap() +} + +pub async fn get_root_posts( + history_stream: impl futures::Stream>, +) -> Vec { + 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(map) = content { + if !map.contains_key("root") { + let text = map.get_key_value("text").unwrap(); + let timestamp_int = msg.value.timestamp.round() as i64 / 1000; + //let timestamp = Utc.timestamp(timestamp_int, 0); + let datetime = NaiveDateTime::from_timestamp(timestamp_int, 0); + //let datetime = timestamp.to_rfc2822(); + let date = datetime.format("%d %b %Y").to_string(); + posts.push(Post { + key: msg.key.to_owned(), + text: text.1.to_string(), + date, + sequence: msg.value.sequence, + read: false, + }) + } + } + } + } + Err(err) => { + // Print the `GolgiError` of this element to `stderr`. + warn!("err: {:?}", err); + } + } + } + + posts } diff --git a/src/task_loop.rs b/src/task_loop.rs new file mode 100644 index 0000000..3bfaaf3 --- /dev/null +++ b/src/task_loop.rs @@ -0,0 +1,36 @@ +use async_std::{channel::Receiver, task}; +use log::{debug, info, warn}; + +use crate::{db::Database, sbot}; + +pub enum Task { + Cancel, + FetchAll(String), +} + +pub async fn spawn(rx: Receiver, db: Database) { + 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 peer tree of the + // database. + Task::FetchAll(peer) => { + let peer_msgs = sbot::get_message_stream(&peer).await; + let root_posts = sbot::get_root_posts(peer_msgs).await; + match db.insert_post_batch(&peer, root_posts) { + Ok(_) => debug!("inserted message batch into peer tree for {}", &peer), + Err(e) => warn!( + "failed to insert message batch into peer tree for {}: {}", + &peer, e + ), + } + } + Task::Cancel => { + info!("exiting task loop..."); + break; + } + } + } + }); +} diff --git a/static/icons/delete_post.png b/static/icons/delete_post.png new file mode 100644 index 0000000000000000000000000000000000000000..572528dd4360aab1322a3eb63f202c4c92ccbed0 GIT binary patch literal 3961 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 NtlJUvLsvq^{{X}dCL{m= literal 0 HcmV?d00001 diff --git a/static/icons/icon_attributions b/static/icons/icon_attributions new file mode 100644 index 0000000..f990896 --- /dev/null +++ b/static/icons/icon_attributions @@ -0,0 +1 @@ +Download icons created by Kiranshastry - Flaticon \ No newline at end of file diff --git a/static/icons/read_post.png b/static/icons/read_post.png new file mode 100644 index 0000000000000000000000000000000000000000..72b4f841c5d2a7b8186f347d113d1b332164a853 GIT binary patch literal 4685 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

6LI+;C1@7S_dI zLwU+!hK3U-KtH>Q)_hI??J6xj<>_YekODc^{hiZKyc(BC23a!%E zJrH?{kx2U3v4>jj_sDCD)gA2pM$1VPUD(XuJ!GYvlA%EgccTQe%lRM_b@{rfs}ggc z^7Th#^&@M!Pc%83Tt=7av9_F*UAvAcnyI-cyl(bB_B`(Tm4gDk$V^3NU`wfK14+?K zaCfJY5Ijb8ui&L2;$W=ETPA0Gny63(U?4EFKr4DZtB3|lm=Y@08%uKZ6+Zn3_xtsU zuLCLN_pwN(axnga8co^(Yg@dY(4u?hZXm_~hZe`T6#5J~x*e8#oEAs@Y?U7h=HLe> z6rU6?2lb_7T=GKFBQ1n*j|7*5sdwNy*U<%NBRe!Q_i^E&mkh69uHi$Pb+PKUgcJ>k z8Jmkv5{#Gfb!BvrrV>n`%N=5Q73w!1GirNkBZ_@o@Yq$GRrQSG;_bh+c|QlPr{bY7 zYC15b#ZlwjOtz@J<9a|hes^-G{`LvA?LVKJ9)iAwkJeo z{dzu6gMoZsA^aYTizH{yG$cZ~!b_5kG;E#X_4}~2NW)#`tybY$nK&C$P4>Gq6$@RK z;Oc2_{lY4xvHREw>BqjY2LyGiAv_rc)=y+WP*=3`)i&kx`%)uZxW(5<5Kq*2A@dXa zt+uQ!)z;tEl{Ol}f(R{Lqm#L`R37Y(>?J6GFTD(`xsjg1n<8JVPFQ@LF zh?z`N7k73HksfEkcarktpVE>I?jEQ@HG5XG5K`cvQ@`8DWBX@g9ntNP+ ze*)!hgYL&v?Po?d-iG3~Gh!YYM2&5>dCTD6!}1Z0w$xo8EfQHPWOzi-D!H%v-qM^{ z*F}f^XBE@5G;uAVfSBG52k4eu?fg3TD~{SVjzE3a*i`!$2JG0&9o4ISVLNeCyDv4> zRy|C}3=W>RY3T)x>2K8A@S(llYFaG|rH4j>c|V(zlpe_m#9tiv4&%-2RxExI3$3@e zb}2U$9=H`}w(@}&gjkuzzE3j}-Z8OY#R9VEon6*@M_|u|gDmSxckzS>^@|$>7GcwBO%Rb}!uyE~uf@ z&sj?xUe5zikKNYpJHo_ngq7Og*>59E9pP^H4_26zPnZm|try`m`H>Jc>m+{|WD7ctT z(>v#(_!Akcy{x>_a*QFwU4uJ(vi*!wM)I2|oknkFQhMae1J!%8^Rjri%VI`al)jpK z8fa=9um!gDCnVoAGVu@z(++twGr|xw`-aXhzkl_2RZ%_vgRn1?pLep@1M%^)i-vui zGpV|}1}38MshP>eX4^WOuXOdDycZgJI&RU5I-L>}1~n zv&1=ri#}WZ$_*4f`p$8*fnKQZ<`pk{p2FtFN^)T~&2lwv*Y%`_WI;H!Pt>DQargYa zs}B6ccfzmyl5hfaBnFC38+VUOYvF#Sk)rDMYUYs}2_;1DwIEC8b>`H)n9Z3lm(aT! zRlsqFcHQ=wPp2K^AqlymxqEgwQFfge{tLp?{wZFO=k-yNljkWc%!gohZ1Ou_Xvq+? zql!pF^ec#$cw@2s^>sXRj+pDM6%|b93jlz+39ERvsTsxOcqOKMCesdzx~VN&6s>LJ z#$s4b%m`2_^_NSSu$~Z7y_f0d@`Wqn3ToJb&Oaon_?gE z^@Oo{i4uV1&n;D@RR%P_ENO!1fnG1!c#|=wze4%(gv>7399j37aR;c=m H9*O@01x&CW literal 0 HcmV?d00001 diff --git a/templates/home.html.tera b/templates/home.html.tera new file mode 100644 index 0000000..e7b320f --- /dev/null +++ b/templates/home.html.tera @@ -0,0 +1,131 @@ + + + + + lykin + + + + + + + +

lykin

+
+ +
+ +
+
+ {% if posts %} + + {% endif %} +
+
+ {% if post %} + {{ post.text }} + {% endif %} +
+
+ +