From 7ae0325d7e54df8db88bc7bc757c3cb674a0fb0f Mon Sep 17 00:00:00 2001 From: Matthias Beyer Date: Wed, 8 Dec 2021 17:50:29 +0100 Subject: Split codebase in subcrates for lib, cli and gui Signed-off-by: Matthias Beyer --- Cargo.toml | 59 +-------- cli/Cargo.toml | 49 +++++++ cli/src/cli.rs | 59 +++++++++ cli/src/commands/mod.rs | 2 + cli/src/commands/profile.rs | 67 ++++++++++ cli/src/main.rs | 27 ++++ gui/Cargo.toml | 44 +++++++ gui/src/cli.rs | 12 ++ gui/src/gui/mod.rs | 186 ++++++++++++++++++++++++++ gui/src/main.rs | 26 ++++ lib/Cargo.toml | 47 +++++++ lib/src/cid.rs | 54 ++++++++ lib/src/client.rs | 315 ++++++++++++++++++++++++++++++++++++++++++++ lib/src/config.rs | 10 ++ lib/src/consts.rs | 4 + lib/src/ipfs_client.rs | 5 + lib/src/lib.rs | 6 + lib/src/profile/mod.rs | 182 +++++++++++++++++++++++++ lib/src/profile/state.rs | 129 ++++++++++++++++++ lib/src/types/datetime.rs | 35 +++++ lib/src/types/mod.rs | 8 ++ lib/src/types/node.rs | 88 +++++++++++++ lib/src/types/payload.rs | 72 ++++++++++ src/cid.rs | 54 -------- src/cli.rs | 59 --------- src/client.rs | 315 -------------------------------------------- src/commands/mod.rs | 2 - src/commands/profile.rs | 67 ---------- src/config.rs | 10 -- src/consts.rs | 4 - src/gui/mod.rs | 186 -------------------------- src/ipfs_client.rs | 5 - src/main.rs | 31 ----- src/profile/mod.rs | 182 ------------------------- src/profile/state.rs | 129 ------------------ src/types/datetime.rs | 35 ----- src/types/mod.rs | 8 -- src/types/node.rs | 88 ------------- src/types/payload.rs | 72 ---------- 39 files changed, 1433 insertions(+), 1300 deletions(-) create mode 100644 cli/Cargo.toml create mode 100644 cli/src/cli.rs create mode 100644 cli/src/commands/mod.rs create mode 100644 cli/src/commands/profile.rs create mode 100644 cli/src/main.rs create mode 100644 gui/Cargo.toml create mode 100644 gui/src/cli.rs create mode 100644 gui/src/gui/mod.rs create mode 100644 gui/src/main.rs create mode 100644 lib/Cargo.toml create mode 100644 lib/src/cid.rs create mode 100644 lib/src/client.rs create mode 100644 lib/src/config.rs create mode 100644 lib/src/consts.rs create mode 100644 lib/src/ipfs_client.rs create mode 100644 lib/src/lib.rs create mode 100644 lib/src/profile/mod.rs create mode 100644 lib/src/profile/state.rs create mode 100644 lib/src/types/datetime.rs create mode 100644 lib/src/types/mod.rs create mode 100644 lib/src/types/node.rs create mode 100644 lib/src/types/payload.rs delete mode 100644 src/cid.rs delete mode 100644 src/cli.rs delete mode 100644 src/client.rs delete mode 100644 src/commands/mod.rs delete mode 100644 src/commands/profile.rs delete mode 100644 src/config.rs delete mode 100644 src/consts.rs delete mode 100644 src/gui/mod.rs delete mode 100644 src/ipfs_client.rs delete mode 100644 src/main.rs delete mode 100644 src/profile/mod.rs delete mode 100644 src/profile/state.rs delete mode 100644 src/types/datetime.rs delete mode 100644 src/types/mod.rs delete mode 100644 src/types/node.rs delete mode 100644 src/types/payload.rs diff --git a/Cargo.toml b/Cargo.toml index d8a78c8..f0e2060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,53 +1,6 @@ -[package] -name = "distrox" -version = "0.1.0" -authors = ["Matthias Beyer "] - -description = "Distributed network build on IPFS" - -keywords = ["social", "network", "ipfs", "distributed"] -readme = "README.md" -license = "GPL-2.0" - -documentation = "https://docs.rs/distrox" -repository = "https://github.com/matthiasbeyer/distrox" -homepage = "http://github.com/matthiasbeyer/distrox" - -edition = "2018" - -[dependencies] -anyhow = "1" -async-trait = "0.1" -chrono = { version = "0.4", features = ["serde"] } -cid = "0.5" -clap = "3.0.0-beta.5" -daglib = { git = "https://git.sr.ht/~matthiasbeyer/daglib", branch = "master" } -env_logger = "0.8" -futures = "0.3" -log = "0.4" -tokio = { version = "1", features = ["full", "rt", "macros"] } -mime = "0.3" -rand_core = { version = "0.6", features = ["getrandom"] } -rand_os = "0.2" -ed25519-dalek = "*" -http = "0.2" -serde = "1" -serde_json = "1" -getset = "0.1" -xdg = "2.4" -tracing = "0.1" -ctrlc = "3.2" - -iced_native = "0.4.0" -iced_wgpu = "0.4.0" - -[dependencies.ipfs] -git = "https://github.com/rs-ipfs/rust-ipfs/" -rev = "ad3ab49b4d9236363969b0f74f14aabc7c906b3b" - -[dependencies.iced] -version = "0.3.0" -features = ["glow", "tokio", "debug"] - -[dev-dependencies] -multibase = "0.8" +[workspace] +members = [ + "cli", + "gui", + "lib", +] diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..e558c19 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "distrox-cli" +version = "0.1.0" +authors = ["Matthias Beyer "] + +description = "Distributed network build on IPFS, CLI frontend" + +keywords = ["social", "network", "ipfs", "distributed"] +readme = "README.md" +license = "GPL-2.0" + +documentation = "https://docs.rs/distrox" +repository = "https://github.com/matthiasbeyer/distrox" +homepage = "http://github.com/matthiasbeyer/distrox" + +edition = "2018" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +cid = "0.5" +clap = "3.0.0-beta.5" +daglib = { git = "https://git.sr.ht/~matthiasbeyer/daglib", branch = "master" } +env_logger = "0.8" +futures = "0.3" +log = "0.4" +tokio = { version = "1", features = ["full", "rt", "macros"] } +mime = "0.3" +rand_core = { version = "0.6", features = ["getrandom"] } +rand_os = "0.2" +ed25519-dalek = "*" +http = "0.2" +serde = "1" +serde_json = "1" +getset = "0.1" +xdg = "2.4" +tracing = "0.1" +ctrlc = "3.2" + +[dependencies.ipfs] +git = "https://github.com/rs-ipfs/rust-ipfs/" +rev = "ad3ab49b4d9236363969b0f74f14aabc7c906b3b" + +[dependencies.distrox-lib] +path = "../lib" + +[dev-dependencies] +multibase = "0.8" diff --git a/cli/src/cli.rs b/cli/src/cli.rs new file mode 100644 index 0000000..3f74128 --- /dev/null +++ b/cli/src/cli.rs @@ -0,0 +1,59 @@ +use clap::crate_authors; +use clap::crate_version; +use clap::App; +use clap::Arg; + +pub fn app<'a>() -> App<'a> { + App::new("distrox") + .author(crate_authors!()) + .version(crate_version!()) + .about("Distributed social network") + + .subcommand(App::new("profile") + .author(crate_authors!()) + .version(crate_version!()) + .about("Profile actions") + + .subcommand(App::new("create") + .author(crate_authors!()) + .version(crate_version!()) + .about("Create profile") + + .arg(Arg::new("name") + .long("name") + .required(true) + .takes_value(true) + .value_name("NAME") + .about("Name of the profile") + ) + ) + + .subcommand(App::new("serve") + .author(crate_authors!()) + .version(crate_version!()) + .about("Just serve the profile") + + .arg(Arg::new("name") + .long("name") + .required(true) + .takes_value(true) + .value_name("NAME") + .about("Name of the profile") + ) + + .arg(Arg::new("connect") + .long("connect") + .required(false) + .takes_value(true) + .value_name("MULTIADDR") + .about("Connect to MULTIADDR as well") + ) + ) + ) + + .subcommand(App::new("gui") + .author(crate_authors!()) + .version(crate_version!()) + .about("Start the distrox gui") + ) +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs new file mode 100644 index 0000000..5569bcb --- /dev/null +++ b/cli/src/commands/mod.rs @@ -0,0 +1,2 @@ +mod profile; +pub use profile::profile; diff --git a/cli/src/commands/profile.rs b/cli/src/commands/profile.rs new file mode 100644 index 0000000..0eb8b75 --- /dev/null +++ b/cli/src/commands/profile.rs @@ -0,0 +1,67 @@ +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use clap::ArgMatches; + +use distrox_lib::config::Config; +use distrox_lib::profile::Profile; + +pub async fn profile(matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("create", m)) => profile_create(m).await, + Some(("serve", m)) => profile_serve(m).await, + _ => unimplemented!(), + } +} + +async fn profile_create(matches: &ArgMatches) -> Result<()> { + let name = matches.value_of("name").map(String::from).unwrap(); // required + let state_dir = Profile::state_dir_path(&name)?; + log::info!("Creating '{}' in {}", name, state_dir.display()); + + let profile = Profile::create(&state_dir, &name, Config::default()).await?; + log::info!("Saving..."); + profile.save().await?; + + log::info!("Shutting down..."); + profile.exit().await +} + +async fn profile_serve(matches: &ArgMatches) -> Result<()> { + use ipfs::MultiaddrWithPeerId; + + let name = matches.value_of("name").map(String::from).unwrap(); // required + let connect_peer = matches.value_of("connect").map(|s| { + s.parse::() + .map_err(anyhow::Error::from) + }).transpose()?; + + let state_dir = Profile::state_dir_path(&name)?; + + log::info!("Loading '{}' from {}", name, state_dir.display()); + let profile = Profile::load(Config::default(), &name).await?; + log::info!("Profile loaded"); + log::info!("Profile HEAD = {:?}", profile.head()); + + if let Some(connect_to) = connect_peer { + log::info!("Connecting to {:?}", connect_to); + profile.connect(connect_to).await?; + } + + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + }).context("Error setting Ctrl-C handler")?; + + log::info!("Serving..."); + while running.load(Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(500)).await // sleep not so busy + } + log::info!("Shutting down..."); + profile.exit().await +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..fea466c --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,27 @@ +use anyhow::Result; + +mod cli; +mod commands; + +fn main() -> Result<()> { + let _ = env_logger::try_init()?; + let matches = crate::cli::app().get_matches(); + + match matches.subcommand() { + Some(("profile", matches)) => crate::commands::profile(matches).await, + Some(("gui", _)) => { + unimplemented!() + }, + Some((other, _)) => { + log::error!("No subcommand {} implemented", other); + Ok(()) + }, + + _ => { + log::error!("Don't know what to do"); + Ok(()) + }, + } +} + + diff --git a/gui/Cargo.toml b/gui/Cargo.toml new file mode 100644 index 0000000..d1b7c6d --- /dev/null +++ b/gui/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "distrox-gui" +version = "0.1.0" +authors = ["Matthias Beyer "] + +description = "Distributed network build on IPFS, GUI frontend" + +keywords = ["social", "network", "ipfs", "distributed"] +readme = "README.md" +license = "GPL-2.0" + +documentation = "https://docs.rs/distrox" +repository = "https://github.com/matthiasbeyer/distrox" +homepage = "http://github.com/matthiasbeyer/distrox" + +edition = "2018" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +cid = "0.5" +clap = "3.0.0-beta.5" +env_logger = "0.8" +futures = "0.3" +log = "0.4" +getset = "0.1" +xdg = "2.4" +tracing = "0.1" +ctrlc = "3.2" + +[dependencies.ipfs] +git = "https://github.com/rs-ipfs/rust-ipfs/" +rev = "ad3ab49b4d9236363969b0f74f14aabc7c906b3b" + +[dependencies.iced] +version = "0.3.0" +features = ["glow", "tokio", "debug"] + +[dependencies.distrox-lib] +path = "../lib" + +[dev-dependencies] +multibase = "0.8" + diff --git a/gui/src/cli.rs b/gui/src/cli.rs new file mode 100644 index 0000000..6ebdfb9 --- /dev/null +++ b/gui/src/cli.rs @@ -0,0 +1,12 @@ +use clap::crate_authors; +use clap::crate_version; +use clap::App; +use clap::Arg; + +pub fn app<'a>() -> App<'a> { + App::new("distrox-gui") + .author(crate_authors!()) + .version(crate_version!()) + .about("Distributed social network, GUI frontend") +} + diff --git a/gui/src/gui/mod.rs b/gui/src/gui/mod.rs new file mode 100644 index 0000000..2eb936e --- /dev/null +++ b/gui/src/gui/mod.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use anyhow::Result; +use iced::Application; +use iced::Column; +use iced::Container; +use iced::Element; +use iced::Length; +use iced::Scrollable; +use iced::TextInput; +use iced::scrollable; +use iced::text_input; + +use distrox_lib::profile::Profile; +use distrox_lib::config::Config; +use distrox_lib::ipfs_client::IpfsClient; + +#[derive(Debug)] +enum Distrox { + Loading, + Loaded(State), + FailedToStart, +} + +#[derive(Debug)] +struct State { + profile: Arc, + + scroll: scrollable::State, + input: text_input::State, + input_value: String, +} + +#[derive(Debug, Clone)] +enum Message { + Loaded(Arc), + FailedToLoad, + + InputChanged(String), + CreatePost, + + PostCreated(cid::Cid), + PostCreationFailed(String), +} + +impl Application for Distrox { + type Executor = iced::executor::Default; // tokio + type Message = Message; + type Flags = (); + + fn new(_flags: ()) -> (Self, iced::Command) { + ( + Distrox::Loading, + iced::Command::perform(async { + match Profile::new_inmemory(Config::default()).await { + Err(_) => Message::FailedToLoad, + Ok(instance) => { + Message::Loaded(Arc::new(instance)) + } + } + }, |m: Message| -> Message { m }) + ) + } + + fn title(&self) -> String { + String::from("distrox") + } + + fn update(&mut self, message: Self::Message, _clipboard: &mut iced::Clipboard) -> iced::Command { + match self { + Distrox::Loading => { + match message { + Message::Loaded(profile) => { + let state = State { + profile, + scroll: scrollable::State::default(), + input: text_input::State::default(), + input_value: String::default(), + }; + *self = Distrox::Loaded(state); + } + + Message::FailedToLoad => { + log::error!("Failed to load"); + *self = Distrox::FailedToStart; + } + + _ => {} + + } + } + + Distrox::Loaded(state) => { + match message { + Message::InputChanged(input) => { + state.input_value = input; + } + + Message::CreatePost => { + if !state.input_value.is_empty() { + let profile = state.profile.clone(); + let input = state.input_value.clone(); + iced::Command::perform(async move { + profile.client().post_text_blob(input).await + }, + |res| match res { + Ok(cid) => Message::PostCreated(cid), + Err(e) => Message::PostCreationFailed(e.to_string()) + }); + } + } + + _ => {} + } + } + + Distrox::FailedToStart => { + unimplemented!() + } + } + iced::Command::none() + } + + fn view(&mut self) -> iced::Element { + match self { + Distrox::Loading => { + let text = iced_native::widget::text::Text::new("Loading"); + + let content = Column::new() + .max_width(800) + .spacing(20) + .push(text); + + Container::new(content) + .width(Length::Fill) + .center_x() + .into() + } + + Distrox::Loaded(state) => { + let input = TextInput::new( + &mut state.input, + "What do you want to tell the world?", + &mut state.input_value, + Message::InputChanged, + ) + .padding(15) + .size(30) + .on_submit(Message::CreatePost); + + let content = Column::new() + .max_width(800) + .spacing(20) + .push(input); + + Scrollable::new(&mut state.scroll) + .padding(40) + .push( + Container::new(content).width(Length::Fill).center_x(), + ) + .into() + } + + Distrox::FailedToStart => { + unimplemented!() + } + } + } + +} + +pub fn run() -> Result<()> { + let settings = iced::Settings { + window: iced::window::Settings { + resizable: true, + decorations: true, + transparent: false, + always_on_top: false, + ..iced::window::Settings::default() + }, + exit_on_close_request: true, + ..iced::Settings::default() + }; + + Distrox::run(settings).map_err(anyhow::Error::from) +} diff --git a/gui/src/main.rs b/gui/src/main.rs new file mode 100644 index 0000000..ea47a93 --- /dev/null +++ b/gui/src/main.rs @@ -0,0 +1,26 @@ +use anyhow::Result; + +mod cli; +mod gui; + +use distrox_lib::*; + +fn main() -> Result<()> { + let _ = env_logger::try_init()?; + let matches = crate::cli::app().get_matches(); + + match matches.subcommand() { + None => crate::gui::run(), + Some((other, _)) => { + log::error!("No subcommand {} implemented", other); + Ok(()) + }, + + _ => { + log::error!("Don't know what to do"); + Ok(()) + }, + } +} + + diff --git a/lib/Cargo.toml b/lib/Cargo.toml new file mode 100644 index 0000000..0655384 --- /dev/null +++ b/lib/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "distrox-lib" +version = "0.1.0" +authors = ["Matthias Beyer "] + +description = "Distributed network build on IPFS" + +keywords = ["social", "network", "ipfs", "distributed"] +readme = "README.md" +license = "GPL-2.0" + +documentation = "https://docs.rs/distrox" +repository = "https://github.com/matthiasbeyer/distrox" +homepage = "http://github.com/matthiasbeyer/distrox" + +edition = "2018" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +cid = "0.5" +clap = "3.0.0-beta.5" +daglib = { git = "https://git.sr.ht/~matthiasbeyer/daglib", branch = "master" } +env_logger = "0.8" +futures = "0.3" +log = "0.4" +tokio = { version = "1", features = ["full", "rt", "macros"] } +mime = "0.3" +rand_core = { version = "0.6", features = ["getrandom"] } +rand_os = "0.2" +ed25519-dalek = "*" +http = "0.2" +serde = "1" +serde_json = "1" +getset = "0.1" +xdg = "2.4" +tracing = "0.1" +ctrlc = "3.2" +libp2p = "0.39.0" + +[dependencies.ipfs] +git = "https://github.com/rs-ipfs/rust-ipfs/" +rev = "ad3ab49b4d9236363969b0f74f14aabc7c906b3b" + +[dev-dependencies] +multibase = "0.8" diff --git a/lib/src/cid.rs b/lib/src/cid.rs new file mode 100644 index 0000000..2957cc5 --- /dev/null +++ b/lib/src/cid.rs @@ -0,0 +1,54 @@ +use anyhow::Result; + +/// Our own CID type +/// +/// Right now the ipfs_api crate does not use a CID type in its interface... hence we would need to +/// convert back-and-forth between String and cid::Cid,... but that's tedious. +/// +/// Hence we just create our own "Cid type" and use that as long as the crate API is stringly +/// typed. +#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct Cid(String); + +impl AsRef for Cid { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +pub trait TryToCid { + fn try_to_cid(self) -> Result; +} + +impl daglib::NodeId for Cid { +} + +/// Helper function that can be tested +/// +/// Converts a String to a Cid +#[cfg(not(test))] +fn string_to_cid(s: String) -> Result { + string_to_cid_impl(s) +} + +#[cfg(test)] +pub fn string_to_cid(s: String) -> Result { + string_to_cid_impl(s) +} + +fn string_to_cid_impl(s: String) -> Result { + Ok(Cid(s)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_string_to_cid() { + let s = String::from("QmY2T5EfgLn8qWCt8eus6VX1gJuAp1nmUSdmoehgMxznAf"); + let r = string_to_cid(s); + assert!(r.is_ok(), "Not OK = {:?}", r); + } +} diff --git a/lib/src/client.rs b/lib/src/client.rs new file mode 100644 index 0000000..bb280a5 --- /dev/null +++ b/lib/src/client.rs @@ -0,0 +1,315 @@ +use std::convert::TryFrom; + +use anyhow::Result; +use ipfs::Cid; + +use crate::config::Config; +use crate::ipfs_client::IpfsClient; +use crate::types::Node; +use crate::types::Payload; +use crate::types::DateTime; + +pub struct Client { + pub(crate) ipfs: IpfsClient, + config: Config, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Client {{ config: {:?} }}", self.config) + } +} + +impl Client { + pub fn new(ipfs: IpfsClient, config: Config) -> Self { + Client { + ipfs, + config + } + } + + pub async fn exit(self) -> Result<()> { + self.ipfs.exit_daemon().await; + Ok(()) + } + + pub async fn connect(&self, peer: ipfs::MultiaddrWithPeerId) -> Result<()> { + self.ipfs.connect(peer).await + } + + pub async fn post_text_blob(&self, text: String) -> Result { + self.ipfs + .put_dag(text.into()) + .await + .map_err(anyhow::Error::from) + } + + /// Post a text node + /// + /// Pass in the parents if there are any. + /// + /// # Note + /// + /// Does not verify if the `parents` cids point to actual Nodes! + /// + /// # Returns + /// + /// Returns the Cid of the newly created node, or an error + pub async fn post_text_node(&self, parents: Vec, text: String) -> Result { + self.post_text_node_with_datetime(parents, text, now()).await + } + + // For testing + async fn post_text_node_with_datetime(&self, parents: Vec, text: String, datetime: DateTime) -> Result { + let text_blob_cid = self.post_text_blob(text).await?; + + let payload = Payload::new(mime::TEXT_PLAIN_UTF_8.as_ref().to_string(), datetime, text_blob_cid); + let payload_cid = self.post_payload(payload).await?; + + let node = Node::new(crate::consts::protocol_version(), parents, payload_cid); + self.post_node(node).await + } + + async fn post_payload(&self, payload: Payload) -> Result { + self.post(payload).await + } + + async fn post_node(&self, node: Node) -> Result { + self.post(node).await + } + + async fn post>(&self, s: S) -> Result { + self.ipfs.put_dag(s.into()).await.map_err(anyhow::Error::from) + } + + pub async fn get_node(&self, cid: Cid) -> Result { + self.get::(cid).await + } + + pub async fn get_payload(&self, cid: Cid) -> Result { + self.get::(cid).await + } + + async fn get>(&self, cid: Cid) -> Result { + let ipld = self.ipfs + .get_dag(ipfs::IpfsPath::new(ipfs::path::PathRoot::Ipld(cid))) + .await?; + + D::try_from(ipld) + } + + pub async fn get_content_text(&self, cid: Cid) -> Result { + struct S(String); + impl TryFrom for S { + type Error = anyhow::Error; + fn try_from(ipld: ipfs::Ipld) -> Result { + match ipld { + ipfs::Ipld::String(s) => Ok(S(s)), + _ => anyhow::bail!("Not a string"), + } + } + } + + self.get::(cid).await.map(|v| v.0) + } +} + +fn now() -> DateTime { + chrono::offset::Utc::now().into() +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use cid::Cid; + + use crate::client::Client; + use crate::config::Config; + use crate::ipfs_client::IpfsClient; + use crate::types::DateTime; + + fn mkdate(y: i32, m: u32, d: u32, hr: u32, min: u32, sec: u32) -> crate::types::DateTime { + use chrono::TimeZone; + + chrono::prelude::Utc.ymd(y, m, d).and_hms(hr, min, sec).into() + } + + async fn mk_ipfs() -> IpfsClient { + let mut opts = ipfs::IpfsOptions::inmemory_with_generated_keys(); + opts.mdns = false; + let (ipfs, fut): (ipfs::Ipfs, _) = ipfs::UninitializedIpfs::new(opts).start().await.unwrap(); + tokio::task::spawn(fut); + ipfs + } + + #[tokio::test] + async fn test_post_text_blob() { + let _ = env_logger::try_init(); + let ipfs = mk_ipfs().await; + let config = Config::default(); + let client = Client::new(ipfs, config); + + let cid = client.post_text_blob(String::from("text")).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from("bafyreienmqqpz622nxgi7xvcx2jf7p3lyagqkwcj5ieil3mhx2zckfl35u").unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + } + + #[tokio::test] + async fn test_post_text_node() { + let _ = env_logger::try_init(); + let ipfs = mk_ipfs().await; + let config = Config::default(); + let client = Client::new(ipfs, config); + + let datetime = mkdate(2021, 11, 27, 12, 30, 0); + + let cid = client.post_text_node_with_datetime(Vec::new(), String::from("text"), datetime).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from("bafyreidem25zq66ktf42l2sjlxmbz5f66bedw3i4ippshhb3h7dxextfty").unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + } + + #[tokio::test] + async fn test_post_text_node_roundtrip() { + let _ = env_logger::try_init(); + let ipfs = mk_ipfs().await; + let config = Config::default(); + let client = Client::new(ipfs, config); + + let datetime = mkdate(2021, 11, 27, 12, 30, 0); + + let text = "text-roundtrip"; + + let cid = client.post_text_node_with_datetime(Vec::new(), String::from(text), datetime.clone()).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from("bafyreicwvx755ysg7zfflxhwhl4d6wuuxmmgfexjfvdhgndiugj37bsphq").unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + + let node = client.get_node(cid).await; + assert!(node.is_ok()); + let node = node.unwrap(); + + assert_eq!(*node.version(), crate::consts::protocol_version()); + assert!(node.parents().is_empty()); + + let payload = client.get_payload(node.payload().clone()).await; + assert!(payload.is_ok()); + let payload = payload.unwrap(); + + assert_eq!(payload.mime(), mime::TEXT_PLAIN_UTF_8.as_ref()); + assert_eq!(payload.timestamp(), &datetime); + + let content = client.get_content_text(payload.content().clone()).await; + assert!(content.is_ok(), "not ok: {:?}", content); + let content = content.unwrap(); + + assert_eq!(content, text); + } + + #[tokio::test] + async fn test_post_text_chain() { + let _ = env_logger::try_init(); + let ipfs = mk_ipfs().await; + let config = Config::default(); + let client = Client::new(ipfs, config); + + let chain_elements = vec![ + (mkdate(2021, 11, 27, 12, 30, 0), "text1", "bafyreidaxkxog3bssyxxjxlsubgg6wauxbobp7gwyucs6gwzyrtsavb7yu"), + (mkdate(2021, 11, 27, 12, 31, 0), "text2", "bafyreifsgfl6tvcdn42kihjryg7fpjyjgi4v56bud2m2yniqjrrfn3ils4"), + (mkdate(2021, 11, 27, 12, 32, 0), "text3", "bafyreifnim44y6zfsc7jrf4xs3lbawlc4qqmk4tgmbqnflbggmvvuvul7a"), + ]; + + let mut prev: Option = None; + for (datetime, text, expected_cid) in chain_elements { + let parents = if let Some(previous) = prev.as_ref() { + vec![previous.clone()] + } else { + Vec::new() + }; + + let cid = client.post_text_node_with_datetime(parents, String::from(text), datetime.clone()).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from(expected_cid).unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + prev = Some(cid); + } + } + + #[tokio::test] + async fn test_post_text_dag() { + let _ = env_logger::try_init(); + let ipfs = mk_ipfs().await; + let config = Config::default(); + let client = Client::new(ipfs, config); + + async fn post_chain(client: &Client, chain_elements: &Vec<(DateTime, &str, &str)>) { + let mut prev: Option = None; + for (datetime, text, expected_cid) in chain_elements { + let parents = if let Some(previous) = prev.as_ref() { + vec![previous.clone()] + } else { + Vec::new() + }; + + let cid = client.post_text_node_with_datetime(parents, String::from(*text), datetime.clone()).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from(*expected_cid).unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + prev = Some(cid); + } + } + + // The following posts a DAG like this: + // + // * -- * -- * _ + // \ + // * -- * -- * -- * + // / + // * - + + let chain_1_elements = vec![ + (mkdate(2021, 11, 27, 12, 30, 0), "text1", "bafyreidaxkxog3bssyxxjxlsubgg6wauxbobp7gwyucs6gwzyrtsavb7yu"), + (mkdate(2021, 11, 27, 12, 31, 0), "text2", "bafyreifsgfl6tvcdn42kihjryg7fpjyjgi4v56bud2m2yniqjrrfn3ils4"), + (mkdate(2021, 11, 27, 12, 32, 0), "text3", "bafyreifnim44y6zfsc7jrf4xs3lbawlc4qqmk4tgmbqnflbggmvvuvul7a"), + ]; + + let chain_2_elements = vec![ + (mkdate(2021, 11, 27, 12, 32, 0), "text4", "bafyreibfkbslobjydkl3tuiqms7dk243fendyqxi5myqkhxquz7arayuwe"), + (mkdate(2021, 11, 27, 12, 32, 0), "text5", "bafyreicpzj4lfhzsx5pacp2otk7qyyx353lwsvmkp4aplwgvyisg3y4mjm"), + ]; + + post_chain(&client, &chain_1_elements).await; + post_chain(&client, &chain_2_elements).await; + + let cid = client.post_text_node_with_datetime(Vec::new(), String::from("text6"), mkdate(2021, 11, 27, 12, 32, 0)).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from("bafyreifcpqvxzrgmcbdx5omysjfyupsvjxlrfzww5yh75ld7f7ox3vzno4").unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + + let parents = vec![ + // latest node in chain_1_elements + ipfs::Cid::try_from("bafyreifnim44y6zfsc7jrf4xs3lbawlc4qqmk4tgmbqnflbggmvvuvul7a").unwrap(), + + // latest node in chain_2_elements + ipfs::Cid::try_from("bafyreicpzj4lfhzsx5pacp2otk7qyyx353lwsvmkp4aplwgvyisg3y4mjm").unwrap(), + + // single node "text6" + cid + ]; + + let cid = client.post_text_node_with_datetime(parents, String::from("text7"), mkdate(2021, 11, 27, 12, 32, 0)).await; + assert!(cid.is_ok()); + let cid = cid.unwrap(); + let expected_cid = Cid::try_from("bafyreieuac7kvefkiu5ls7tqumaef5qiur7l3moa33ay2kaxxpjmfdjbey").unwrap(); + assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); + } + +} diff --git a/lib/src/config.rs b/lib/src/config.rs new file mode 100644 index 0000000..0d25e78 --- /dev/null +++ b/lib/src/config.rs @@ -0,0 +1,10 @@ +#[derive(Debug)] +pub struct Config { +} + +impl Default for Config { + fn default() -> Self { + Config { } + } +} + diff --git a/lib/src/consts.rs b/lib/src/consts.rs new file mode 100644 index 0000000..79ff2f5 --- /dev/null +++ b/lib/src/consts.rs @@ -0,0 +1,4 @@ +pub fn protocol_version() -> String { + String::from("1") +} + diff --git a/lib/src/ipfs_client.rs b/lib/src/ipfs_client.rs new file mode 100644 index 0000000..964b22c --- /dev/null +++ b/lib/src/ipfs_client.rs @@ -0,0 +1,5 @@ +#[cfg(not(test))] +pub type IpfsClient = ipfs::Ipfs; + +#[cfg(test)] +pub type IpfsClient = ipfs::Ipfs; diff --git a/lib/src/lib.rs b/lib/src/lib.rs new file mode 100644 index 0000000..759ea14 --- /dev/null +++ b/lib/src/lib.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod config; +pub mod consts; +pub mod ipfs_client; +pub mod profile; +pub mod types; diff --git a/lib/src/profile/mod.rs b/lib/src/profile/mod.rs new file mode 100644 index 0000000..e3ce175 --- /dev/null +++ b/lib/src/profile/mod.rs @@ -0,0 +1,182 @@ +use std::path::PathBuf; +use std::convert::TryInto; + +use anyhow::Context; +use anyhow::Result; + +use crate::client::Client; +use crate::config::Config; +use crate::ipfs_client::IpfsClient; + +mod state; +use state::*; + +#[derive(Debug, getset::Getters)] +pub struct Profile { + state: ProfileState, + + #[getset(get = "pub")] + client: Client, +} + +impl Profile { + pub async fn create(state_dir: &StateDir, name: &str, config: Config) -> Result { + let bootstrap = vec![]; // TODO + let mdns = true; // TODO + let keypair = ipfs::Keypair::generate_ed25519(); + + let options = ipfs::IpfsOptions { + ipfs_path: Self::ipfs_path(state_dir).await?, + keypair, + bootstrap, + mdns, + kad_protocol: None, + listening_addrs: vec![], + span: Some(tracing::trace_span!("distrox-ipfs")), + }; + + let keypair = options.keypair.clone(); + let (ipfs, fut): (ipfs::Ipfs<_>, _) = ipfs::UninitializedIpfs::<_>::new(options) + .start() + .await?; + tokio::task::spawn(fut); + Self::new(ipfs, config, name.to_string(), keypair).await + } + + #[cfg(test)] + async fn new_inmemory(config: Config, name: &str) -> Result { + let mut opts = ipfs::IpfsOptions::inmemory_with_generated_keys(); + opts.mdns = true; + let keypair = opts.keypair.clone(); + let (ipfs, fut): (ipfs::Ipfs<_>, _) = ipfs::UninitializedIpfs::<_>::new(opts).start().await.unwrap(); + tokio::task::spawn(fut); + Self::new(ipfs, config, format!("inmemory-{}", name), keypair).await + } + + async fn new(ipfs: IpfsClient, config: Config, profile_name: String, keypair: libp2p::identity::Keypair) -> Result { + let client = Client::new(ipfs, config); + let state = ProfileState::new(profile_name, keypair); + Ok(Profile { state, client }) + } + + pub fn head(&self) -> Option<&cid::Cid> { + self.state.profile_head().as_ref() + } + + pub async fn connect(&self, peer: ipfs::MultiaddrWithPeerId) -> Result<()> { + self.client.connect(peer).await + } + + async fn ipfs_path(state_dir: &StateDir) -> Result { + let path = state_dir.ipfs(); + tokio::fs::create_dir_all(&path).await?; + Ok(path) + } + + pub fn config_path(name: &str) -> String { + format!("distrox-{}", name) + } + + pub fn config_file_path(name: &str) -> Result { + xdg::BaseDirectories::with_prefix("distrox") + .map_err(anyhow::Error::from) + .and_then(|dirs| { + let name = Self::config_path(name); + dirs.place_config_file(name) + .map_err(anyhow::Error::from) + }) + } + + pub fn state_dir_path(name: &str) -> Result { + log::debug!("Getting state directory path"); + xdg::BaseDirectories::with_prefix("distrox") + .context("Fetching 'distrox' XDG base directory") + .map_err(anyhow::Error::from) + .and_then(|dirs| { + dirs.create_state_directory(name) + .map(StateDir::from) + .with_context(|| format!("Creating 'distrox' XDG state directory for '{}'", name)) + .map_err(anyhow::Error::from) + }) + } + + pub async fn save(&self) -> Result<()> { + let state_dir_path = Self::state_dir_path(self.state.profile_name())?; + log::trace!("Saving to {:?}", state_dir_path.display()); + ProfileStateSaveable::new(&self.state) + .context("Serializing profile state")? + .save_to_disk(&state_dir_path) + .await + .context("Saving state to disk") + .map_err(anyhow::Error::from) + } + + pub async fn load(config: Config, name: &str) -> Result { + let state_dir_path = Self::state_dir_path(name)?; + log::trace!("state_dir_path = {:?}", state_dir_path.display()); + let state: ProfileState = ProfileStateSaveable::load_from_disk(&state_dir_path) + .await? + .try_into() + .context("Parsing profile state")?; + log::debug!("Loading state finished"); + + let bootstrap = vec![]; // TODO + let mdns = true; // TODO + let keypair = state.keypair().clone(); + + log::debug!("Configuring IPFS backend"); + let options = ipfs::IpfsOptions { + ipfs_path: Self::ipfs_path(&state_dir_path).await?, + keypair, + bootstrap, + mdns, + kad_protocol: None, + listening_addrs: vec![], + span: Some(tracing::trace_span!("distrox-ipfs")), + }; + + log::debug!("Starting IPFS backend"); + let (ipfs, fut): (ipfs::Ipfs<_>, _) = ipfs::UninitializedIpfs::<_>::new(options) + .start() + .await?; + tokio::task::spawn(fut); + + log::debug!("Profile loading finished"); + Ok(Profile { + state, + client: Client::new(ipfs, config), + }) + } + + pub async fn exit(self) -> Result<()> { + self.client.exit().await + } + +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::convert::TryFrom; + use crate::config::Config; + + #[tokio::test] + async fn test_create_profile() { + let _ = env_logger::try_init(); + let profile = Profile::new_inmemory(Config::default(), "test-create-profile").await; + assert!(profile.is_ok()); + let exit = profile.unwrap().exit().await; + assert!(exit.is_ok(), "Not cleanly exited: {:?}", exit); + } + + #[tokio::test] + async fn test_create_profile_and_helloworld() { + let _ = env_logger::try_init(); + let profile = Profile::new_inmemory(Config::default(), "test-create-profile-and-helloworld").await; + assert!(profile.is_ok()); + let profile = profile.unwrap(); + assert!(profile.head().is_none()); + } + +} diff --git a/lib/src/profile/state.rs b/lib/src/profile/state.rs new file mode 100644 index 0000000..4075b52 --- /dev/null +++ b/lib/src/profile/state.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; +use std::convert::TryFrom; +use std::convert::TryInto; + +use anyhow::Context; +use anyhow::Result; +use tokio::io::AsyncWriteExt; + +#[derive(Debug)] +pub struct StateDir(PathBuf); + +impl StateDir { + pub fn ipfs(&self) -> PathBuf { + self.0.join("ipfs") + } + + pub fn profile_state(&self) -> PathBuf { + self.0.join("profile_state") + } + + pub fn display(&self) -> std::path::Display { + self.0.display() + } +} + +impl From for StateDir { + fn from(p: PathBuf) -> Self { + Self(p) + } +} + +#[derive(getset::Getters)] +pub struct ProfileState { + #[getset(get = "pub")] + profile_head: Option, + + #[getset(get = "pub")] + profile_name: String, + + #[getset(get = "pub")] + keypair: libp2p::identity::Keypair, +} + +impl ProfileState { + pub(super) fn new(profile_name: String, keypair: libp2p::identity::Keypair) -> Self { + Self { + profile_head: None, + profile_name, + keypair + } + } +} + +impl std::fmt::Debug for ProfileState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ProfileState {{ name = {}, head = {:?} }}", self.profile_name, self.profile_head) + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, getset::Getters)] +pub(super) struct ProfileStateSaveable { + profile_head: Option>, + profile_name: String, + keypair: Vec, +} + +impl ProfileStateSaveable { + pub(super) fn new(s: &ProfileState) -> Result { + Ok(Self { + profile_head: s.profile_head.clone().map(|v| v.to_bytes()), + profile_name: s.profile_name.clone(), + keypair: match s.keypair { + libp2p::identity::Keypair::Ed25519(ref kp) => Vec::from(kp.encode()), + _ => anyhow::bail!("Only keypair type ed25519 supported"), + } + }) + } + + pub async fn save_to_disk(&self, state_dir_path: &StateDir) -> Result<()> { + let state_s = serde_json::to_string(&self).context("Serializing state")?; + tokio::fs::OpenOptions::new() + .create_new(false) // do not _always_ create a new file + .create(true) + .truncate(true) + .write(true) + .open(&state_dir_path.profile_state()) + .await + .with_context(|| format!("Opening {}", state_dir_path.profile_state().display()))? + .write_all(state_s.as_bytes()) + .await + .map(|_| ()) + .with_context(|| format!("Writing to {}", state_dir_path.profile_state().display())) + .map_err(anyhow::Error::from) + } + + pub async fn load_from_disk(state_dir_path: &StateDir) -> Result { + log::trace!("Loading from disk: {:?}", state_dir_path.profile_state().display()); + let reader = tokio::fs::OpenOptions::new() + .read(true) + .open(&state_dir_path.profile_state()) + .await + .context("Opening state file")? + .into_std() + .await; + + log::trace!("Parsing state file"); + serde_json::from_reader(reader) + .context("Parsing state file") + .map_err(anyhow::Error::from) + } + +} + +impl TryInto for ProfileStateSaveable { + type Error = anyhow::Error; + + fn try_into(mut self) -> Result { + Ok(ProfileState { + profile_head: self.profile_head.map(|h| cid::Cid::try_from(h)).transpose()?, + profile_name: self.profile_name, + keypair: { + let kp = libp2p::identity::ed25519::Keypair::decode(&mut self.keypair)?; + libp2p::identity::Keypair::Ed25519(kp) + }, + }) + } +} + + diff --git a/lib/src/types/datetime.rs b/lib/src/types/datetime.rs new file mode 100644 index 0000000..00d739a --- /dev/null +++ b/lib/src/types/datetime.rs @@ -0,0 +1,35 @@ +use std::convert::TryFrom; +use anyhow::Error; +use anyhow::Result; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct DateTime(chrono::DateTime); + +impl Into for DateTime { + fn into(self) -> ipfs::Ipld { + ipfs::Ipld::String(self.0.to_rfc3339()) + } +} + +impl TryFrom for DateTime { + type Error = Error; + + fn try_from(ipld: ipfs::Ipld) -> Result { + match ipld { + ipfs::Ipld::String(s) => chrono::DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map(DateTime) + .map_err(Error::from), + _ => anyhow::bail!("Expected string for timestamp"), + } + } +} + + +impl From> for DateTime { + fn from(dt: chrono::DateTime) -> Self { + DateTime(dt) + } +} + diff --git a/lib/src/types/mod.rs b/lib/src/types/mod.rs new file mode 100644 index 0000000..7382f16 --- /dev/null +++ b/lib/src/types/mod.rs @@ -0,0 +1,8 @@ +mod node; +pub use node::*; + +mod datetime; +pub use datetime::*; + +mod payload; +pub use payload::*; diff --git a/lib/src/types/node.rs b/lib/src/types/node.rs new file mode 100644 index 0000000..eb5679b --- /dev/null +++ b/lib/src/types/node.rs @@ -0,0 +1,88 @@ +use anyhow::Result; + +use std::convert::TryFrom; + +#[derive(Debug, Eq, PartialEq, getset::Getters)] +pub struct Node { + /// Version + #[getset(get = "pub")] + version: String, + + /// Parent Nodes, identified by cid + parents: Vec, + + /// The actual payload of the node, which is stored in another document identified by this cid + payload: ipfs::Cid, +} + +impl Into for Node { + fn into(self) -> ipfs::Ipld { + let mut map = std::collections::BTreeMap::new(); + map.insert(String::from("version"), ipfs::Ipld::String(self.version)); + map.insert(String::from("parents"), ipfs::Ipld::List(self.parents.into_iter().map(ipfs::Ipld::Link).collect())); + map.insert(String::from("payload"), ipfs::Ipld::Link(self.payload)); + ipfs::Ipld::Map(map) + } +} + +impl TryFrom for Node { + type Error = anyhow::Error; + + fn try_from(ipld: ipfs::Ipld) -> Result { + let missing_field = |name: &'static str| move || anyhow::anyhow!("Missing field {}", name); + let field_wrong_type = |name: &str, expty: &str| anyhow::bail!("Field {} has wrong type, expected {}", name, expty); + match ipld { + ipfs::Ipld::Map(map) => { + let version = match map.get("version").ok_or_else(missing_field("version"))? { + ipfs::Ipld::String(s) => s.to_string(), + _ => return field_wrong_type("version", "String") + }; + + let parents = match map.get("parents").ok_or_else(missing_field("parents"))? { + ipfs::Ipld::List(s) => { + s.into_iter() + .map(|parent| -> Result { + match parent { + ipfs::Ipld::Link(cid) => Ok(cid.clone()), + _ => anyhow::bail!("Field in parents has wrong type, expected Link"), + } + }) + .collect::>>()? + }, + _ => return field_wrong_type("parents", "Vec") + }; + + let payload = match map.get("payload").ok_or_else(missing_field("payload"))? { + ipfs::Ipld::Link(cid) => cid.clone(), + _ => return field_wrong_type("payload", "Link") + }; + + Ok(Node { + version, + parents, + payload + }) + } + + _ => anyhow::bail!("Unexpected type, expected map") + } + } +} + +impl Node { + pub fn new(version: String, parents: Vec, payload: ipfs::Cid) -> Self { + Self { + version, + parents, + payload, + } + } + + pub fn parents(&self) -> Vec { + self.parents.clone() + } + + pub fn payload(&self) -> ipfs::Cid { + self.payload.clone() + } +} diff --git a/lib/src/types/payload.rs b/lib/src/types/payload.rs new file mode 100644 index 0000000..a11b215 --- /dev/null +++ b/lib/src/types/payload.rs @@ -0,0 +1,72 @@ +use std::convert::TryFrom; + +use anyhow::Result; + +use crate::types::DateTime; + +#[derive(Debug, Eq, PartialEq, getset::Getters)] +pub struct Payload { + // TODO: Make this a mime::Mime, but as this type does not impl Serialize/Deserialize, we + // cannot do this trivially yet + #[getset(get = "pub")] + mime: String, + + #[getset(get = "pub")] + timestamp: DateTime, + + content: ipfs::Cid, +} + +impl Into for Payload { + fn into(self) -> ipfs::Ipld { + let mut map = std::collections::BTreeMap::new(); + map.insert(String::from("mime"), ipfs::Ipld::String(self.mime)); + map.insert(String::from("timestamp"), self.timestamp.into()); + map.insert(String::from("content"), ipfs::Ipld::Link(self.content)); + ipfs::Ipld::Map(map) + } +} + +impl TryFrom for Payload { + type Error = anyhow::Error; + + fn try_from(ipld: ipfs::Ipld) -> Result { + let missing_field = |name: &'static str| move || anyhow::anyhow!("Missing field {}", name); + let field_wrong_type = |name: &str, expty: &str| anyhow::bail!("Field {} has wrong type, expected {}", name, expty); + match ipld { + ipfs::Ipld::Map(map) => { + let mime = match map.get("mime").ok_or_else(missing_field("mime"))? { + ipfs::Ipld::String(s) => s.to_owned(), + _ => return field_wrong_type("mime", "String") + }; + + let timestamp = map.get("timestamp") + .ok_or_else(missing_field("timestamp"))?; + let timestamp = DateTime::try_from(timestamp.clone())?; // TODO dont clone + + let content = match map.get("content").ok_or_else(missing_field("content"))? { + ipfs::Ipld::Link(cid) => cid.clone(), + _ => return field_wrong_type("content", "Link") + }; + + Ok(Payload { + mime, + timestamp, + content + }) + }, + + _ => anyhow::bail!("Unexpected type, expected map"), + } + } +} + +impl Payload { + pub fn new(mime: String, timestamp: DateTime, content: ipfs::Cid) -> Self { + Self { mime, timestamp, content: content.into() } + } + + pub fn content(&self) -> ipfs::Cid { + self.content.clone() + } +} diff --git a/src/cid.rs b/src/cid.rs deleted file mode 100644 index 2957cc5..0000000 --- a/src/cid.rs +++ /dev/null @@ -1,54 +0,0 @@ -use anyhow::Result; - -/// Our own CID type -/// -/// Right now the ipfs_api crate does not use a CID type in its interface... hence we would need to -/// convert back-and-forth between String and cid::Cid,... but that's tedious. -/// -/// Hence we just create our own "Cid type" and use that as long as the crate API is stringly -/// typed. -#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(transparent)] -pub struct Cid(String); - -impl AsRef for Cid { - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -pub trait TryToCid { - fn try_to_cid(self) -> Result; -} - -impl daglib::NodeId for Cid { -} - -/// Helper function that can be tested -/// -/// Converts a String to a Cid -#[cfg(not(test))] -fn string_to_cid(s: String) -> Result { - string_to_cid_impl(s) -} - -#[cfg(test)] -pub fn string_to_cid(s: String) -> Result { - string_to_cid_impl(s) -} - -fn string_to_cid_impl(s: String) -> Result { - Ok(Cid(s)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_string_to_cid() { - let s = String::from("QmY2T5EfgLn8qWCt8eus6VX1gJuAp1nmUSdmoehgMxznAf"); - let r = string_to_cid(s); - assert!(r.is_ok(), "Not OK = {:?}", r); - } -} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 3f74128..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,59 +0,0 @@ -use clap::crate_authors; -use clap::crate_version; -use clap::App; -use clap::Arg; - -pub fn app<'a>() -> App<'a> { - App::new("distrox") - .author(crate_authors!()) - .version(crate_version!()) - .about("Distributed social network") - - .subcommand(App::new("profile") - .author(crate_authors!()) - .version(crate_version!()) - .about("Profile actions") - - .subcommand(App::new("create") - .author(crate_authors!()) - .version(crate_version!()) - .about("Create profile") - - .arg(Arg::new("name") - .long("name") - .required(true) - .takes_value(true) - .value_name("NAME") - .about("Name of the profile") - ) - ) - - .subcommand(App::new("serve") - .author(crate_authors!()) - .version(crate_version!()) - .about("Just serve the profile") - - .arg(Arg::new("name") - .long("name") - .required(true) - .takes_value(true) - .value_name("NAME") - .about("Name of the profile") - ) - - .arg(Arg::new("connect") - .long("connect") - .required(false) - .takes_value(true) - .value_name("MULTIADDR") - .about("Connect to MULTIADDR as well") - ) - ) - ) - - .subcommand(App::new("gui") - .author(crate_authors!()) - .version(crate_version!()) - .about("Start the distrox gui") - ) -} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index bb280a5..0000000 --- a/src/client.rs +++ /dev/null @@ -1,315 +0,0 @@ -use std::convert::TryFrom; - -use anyhow::Result; -use ipfs::Cid; - -use crate::config::Config; -use crate::ipfs_client::IpfsClient; -use crate::types::Node; -use crate::types::Payload; -use crate::types::DateTime; - -pub struct Client { - pub(crate) ipfs: IpfsClient, - config: Config, -} - -impl std::fmt::Debug for Client { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "Client {{ config: {:?} }}", self.config) - } -} - -impl Client { - pub fn new(ipfs: IpfsClient, config: Config) -> Self { - Client { - ipfs, - config - } - } - - pub async fn exit(self) -> Result<()> { - self.ipfs.exit_daemon().await; - Ok(()) - } - - pub async fn connect(&self, peer: ipfs::MultiaddrWithPeerId) -> Result<()> { - self.ipfs.connect(peer).await - } - - pub async fn post_text_blob(&self, text: String) -> Result { - self.ipfs - .put_dag(text.into()) - .await - .map_err(anyhow::Error::from) - } - - /// Post a text node - /// - /// Pass in the parents if there are any. - /// - /// # Note - /// - /// Does not verify if the `parents` cids point to actual Nodes! - /// - /// # Returns - /// - /// Returns the Cid of the newly created node, or an error - pub async fn post_text_node(&self, parents: Vec, text: String) -> Result { - self.post_text_node_with_datetime(parents, text, now()).await - } - - // For testing - async fn post_text_node_with_datetime(&self, parents: Vec, text: String, datetime: DateTime) -> Result { - let text_blob_cid = self.post_text_blob(text).await?; - - let payload = Payload::new(mime::TEXT_PLAIN_UTF_8.as_ref().to_string(), datetime, text_blob_cid); - let payload_cid = self.post_payload(payload).await?; - - let node = Node::new(crate::consts::protocol_version(), parents, payload_cid); - self.post_node(node).await - } - - async fn post_payload(&self, payload: Payload) -> Result { - self.post(payload).await - } - - async fn post_node(&self, node: Node) -> Result { - self.post(node).await - } - - async fn post>(&self, s: S) -> Result { - self.ipfs.put_dag(s.into()).await.map_err(anyhow::Error::from) - } - - pub async fn get_node(&self, cid: Cid) -> Result { - self.get::(cid).await - } - - pub async fn get_payload(&self, cid: Cid) -> Result { - self.get::(cid).await - } - - async fn get>(&self, cid: Cid) -> Result { - let ipld = self.ipfs - .get_dag(ipfs::IpfsPath::new(ipfs::path::PathRoot::Ipld(cid))) - .await?; - - D::try_from(ipld) - } - - pub async fn get_content_text(&self, cid: Cid) -> Result { - struct S(String); - impl TryFrom for S { - type Error = anyhow::Error; - fn try_from(ipld: ipfs::Ipld) -> Result { - match ipld { - ipfs::Ipld::String(s) => Ok(S(s)), - _ => anyhow::bail!("Not a string"), - } - } - } - - self.get::(cid).await.map(|v| v.0) - } -} - -fn now() -> DateTime { - chrono::offset::Utc::now().into() -} - -#[cfg(test)] -mod tests { - use std::convert::TryFrom; - - use cid::Cid; - - use crate::client::Client; - use crate::config::Config; - use crate::ipfs_client::IpfsClient; - use crate::types::DateTime; - - fn mkdate(y: i32, m: u32, d: u32, hr: u32, min: u32, sec: u32) -> crate::types::DateTime { - use chrono::TimeZone; - - chrono::prelude::Utc.ymd(y, m, d).and_hms(hr, min, sec).into() - } - - async fn mk_ipfs() -> IpfsClient { - let mut opts = ipfs::IpfsOptions::inmemory_with_generated_keys(); - opts.mdns = false; - let (ipfs, fut): (ipfs::Ipfs, _) = ipfs::UninitializedIpfs::new(opts).start().await.unwrap(); - tokio::task::spawn(fut); - ipfs - } - - #[tokio::test] - async fn test_post_text_blob() { - let _ = env_logger::try_init(); - let ipfs = mk_ipfs().await; - let config = Config::default(); - let client = Client::new(ipfs, config); - - let cid = client.post_text_blob(String::from("text")).await; - assert!(cid.is_ok()); - let cid = cid.unwrap(); - let expected_cid = Cid::try_from("bafyreienmqqpz622nxgi7xvcx2jf7p3lyagqkwcj5ieil3mhx2zckfl35u").unwrap(); - assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); - } - - #[tokio::test] - async fn test_post_text_node() { - let _ = env_logger::try_init(); - let ipfs = mk_ipfs().await; - let config = Config::default(); - let client = Client::new(ipfs, config); - - let datetime = mkdate(2021, 11, 27, 12, 30, 0); - - let cid = client.post_text_node_with_datetime(Vec::new(), String::from("text"), datetime).await; - assert!(cid.is_ok()); - let cid = cid.unwrap(); - let expected_cid = Cid::try_from("bafyreidem25zq66ktf42l2sjlxmbz5f66bedw3i4ippshhb3h7dxextfty").unwrap(); - assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); - } - - #[tokio::test] - async fn test_post_text_node_roundtrip() { - let _ = env_logger::try_init(); - let ipfs = mk_ipfs().await; - let config = Config::default(); - let client = Client::new(ipfs, config); - - let datetime = mkdate(2021, 11, 27, 12, 30, 0); - - let text = "text-roundtrip"; - - let cid = client.post_text_node_with_datetime(Vec::new(), String::from(text), datetime.clone()).await; - assert!(cid.is_ok()); - let cid = cid.unwrap(); - let expected_cid = Cid::try_from("bafyreicwvx755ysg7zfflxhwhl4d6wuuxmmgfexjfvdhgndiugj37bsphq").unwrap(); - assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); - - let node = client.get_node(cid).await; - assert!(node.is_ok()); - let node = node.unwrap(); - - assert_eq!(*node.version(), crate::consts::protocol_version()); - assert!(node.parents().is_empty()); - - let payload = client.get_payload(node.payload().clone()).await; - assert!(payload.is_ok()); - let payload = payload.unwrap(); - - assert_eq!(payload.mime(), mime::TEXT_PLAIN_UTF_8.as_ref()); - assert_eq!(payload.timestamp(), &datetime); - - let content = client.get_content_text(payload.content().clone()).await; - assert!(content.is_ok(), "not ok: {:?}", content); - let content = content.unwrap(); - - assert_eq!(content, text); - } - - #[tokio::test] - async fn test_post_text_chain() { - let _ = env_logger::try_init(); - let ipfs = mk_ipfs().await; - let config = Config::default(); - let client = Client::new(ipfs, config); - - let chain_elements = vec![ - (mkdate(2021, 11, 27, 12, 30, 0), "text1", "bafyreidaxkxog3bssyxxjxlsubgg6wauxbobp7gwyucs6gwzyrtsavb7yu"), - (mkdate(2021, 11, 27, 12, 31, 0), "text2", "bafyreifsgfl6tvcdn42kihjryg7fpjyjgi4v56bud2m2yniqjrrfn3ils4"), - (mkdate(2021, 11, 27, 12, 32, 0), "text3", "bafyreifnim44y6zfsc7jrf4xs3lbawlc4qqmk4tgmbqnflbggmvvuvul7a"), - ]; - - let mut prev: Option = None; - for (datetime, text, expected_cid) in chain_elements { - let parents = if let Some(previous) = prev.as_ref() { - vec![previous.clone()] - } else { - Vec::new() - }; - - let cid = client.post_text_node_with_datetime(parents, String::from(text), datetime.clone()).await; - assert!(cid.is_ok()); - let cid = cid.unwrap(); - let expected_cid = Cid::try_from(expected_cid).unwrap(); - assert_eq!(cid, expected_cid, "{} != {}", cid, expected_cid); - prev = Some(cid); - } - } - - #[tokio::test] - async fn test_post_text_dag() { - let _ = env_logger::try_init(); - let ipfs = mk_ipfs().await; - let config = Config::default(); - let client = Client::new(ipfs, config); - - async fn post_chain(client: &Client, chain_elements: &Vec<(DateTime, &str, &str)>) { - let mut prev: Option = None; -