diff options
Diffstat (limited to 'lib/src')
-rw-r--r-- | lib/src/cid.rs | 54 | ||||
-rw-r--r-- | lib/src/client.rs | 321 | ||||
-rw-r--r-- | lib/src/config.rs | 10 | ||||
-rw-r--r-- | lib/src/consts.rs | 4 | ||||
-rw-r--r-- | lib/src/ipfs_client.rs | 5 | ||||
-rw-r--r-- | lib/src/lib.rs | 6 | ||||
-rw-r--r-- | lib/src/profile/mod.rs | 181 | ||||
-rw-r--r-- | lib/src/profile/state.rs | 129 | ||||
-rw-r--r-- | lib/src/types/datetime.rs | 35 | ||||
-rw-r--r-- | lib/src/types/mod.rs | 8 | ||||
-rw-r--r-- | lib/src/types/node.rs | 88 | ||||
-rw-r--r-- | lib/src/types/payload.rs | 72 |
12 files changed, 913 insertions, 0 deletions
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<str> for Cid { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +pub trait TryToCid { + fn try_to_cid(self) -> Result<Cid>; +} + +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<Cid> { + string_to_cid_impl(s) +} + +#[cfg(test)] +pub fn string_to_cid(s: String) -> Result<Cid> { + string_to_cid_impl(s) +} + +fn string_to_cid_impl(s: String) -> Result<Cid> { + 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..da8ad94 --- /dev/null +++ b/lib/src/client.rs @@ -0,0 +1,321 @@ +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<Cid> { + 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<Cid>, text: String) -> Result<Cid> { + self.post_text_node_with_datetime(parents, text, now()).await + } + + // For testing + async fn post_text_node_with_datetime(&self, parents: Vec<Cid>, text: String, datetime: DateTime) -> Result<Cid> { + 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<Cid> { + self.post(payload).await + } + + async fn post_node(&self, node: Node) -> Result<Cid> { + self.post(node).await + } + + async fn post<S: Into<ipfs::Ipld>>(&self, s: S) -> Result<Cid> { + let cid = self.ipfs.put_dag(s.into()).await?; + self.pin(&cid).await?; + Ok(cid) + } + + async fn pin(&self, cid: &cid::Cid) -> Result<()> { + self.ipfs.insert_pin(cid, false).await.map_err(anyhow::Error::from) + } + + pub async fn get_node(&self, cid: Cid) -> Result<Node> { + self.get::<Node>(cid).await + } + + pub async fn get_payload(&self, cid: Cid) -> Result<Payload> { + self.get::<Payload>(cid).await + } + + async fn get<D: TryFrom<ipfs::Ipld, Error = anyhow::Error>>(&self, cid: Cid) -> Result<D> { + 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<String> { + struct S(String); + impl TryFrom<ipfs::Ipld> for S { + type Error = anyhow::Error; + fn try_from(ipld: ipfs::Ipld) -> Result<Self> { + match ipld { + ipfs::Ipld::String(s) => Ok(S(s)), + _ => anyhow::bail!("Not a string"), + } + } + } + + self.get::<S>(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::TestTypes>, _) = 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<ipfs::Cid> = 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<ipfs::Cid> = 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<ipfs::Types>; + +#[cfg(test)] +pub type IpfsClient = ipfs::Ipfs<ipfs::TestTypes>; 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..db3c593 --- /dev/null +++ b/lib/src/profile/mod.rs @@ -0,0 +1,181 @@ +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<Self> { + 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 + } + + pub async fn new_inmemory(config: Config, name: &str) -> Result<Self> { + 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<Self> { + 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<PathBuf> { + 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<PathBuf> { + 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<StateDir> { + 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<Self> { + 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<PathBuf> for StateDir { + fn from(p: PathBuf) -> Self { + Self(p) + } +} + +#[derive(getset::Getters)] +pub struct ProfileState { + #[getset(get = "pub")] + profile_head: Option<cid::Cid>, + + #[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<Vec<u8>>, + profile_name: String, + keypair: Vec<u8>, +} + +impl ProfileStateSaveable { + pub(super) fn new(s: &ProfileState) -> Result<Self> { + 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<Self> { + 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<ProfileState> for ProfileStateSaveable { + type Error = anyhow::Error; + + fn try_into(mut self) -> Result<ProfileState> { + 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<chrono::Utc>); + +impl Into<ipfs::Ipld> for DateTime { + fn into(self) -> ipfs::Ipld { + ipfs::Ipld::String(self.0.to_rfc3339()) + } +} + +impl TryFrom<ipfs::Ipld> for DateTime { + type Error = Error; + + fn try_from(ipld: ipfs::Ipld) -> Result<DateTime> { + 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<chrono::DateTime<chrono::Utc>> for DateTime { + fn from(dt: chrono::DateTime<chrono::Utc>) -> 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<ipfs::Cid>, + + /// The actual payload of the node, which is stored in another document identified by this cid + payload: ipfs::Cid, +} + +impl Into<ipfs::Ipld> 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<ipfs::Ipld> for Node { + type Error = anyhow::Error; + + fn try_from(ipld: ipfs::Ipld) -> Result<Self> { + 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<ipfs::Cid> { + match parent { + ipfs::Ipld::Link(cid) => Ok(cid.clone()), + _ => anyhow::bail!("Field in parents has wrong type, expected Link"), + } + }) + .collect::<Result<Vec<ipfs::Cid>>>()? + }, + _ => return field_wrong_type("parents", "Vec<Link>") + }; + + 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<ipfs::Cid>, payload: ipfs::Cid) -> Self { + Self { + version, + parents, + payload, + } + } + + pub fn parents(&self) -> Vec<ipfs::Cid> { + 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<ipfs::Ipld> 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<ipfs::Ipld> for Payload { + type Error = anyhow::Error; + + fn try_from(ipld: ipfs::Ipld) -> Result<Self> { + 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() + } +} |