diff --git a/Cargo.lock b/Cargo.lock index 48c4fc1..4c25343 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -486,6 +486,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -557,9 +586,9 @@ dependencies = [ [[package]] name = "crypto-mac" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bff07008ec701e8028e2ceb8f83f0e4274ee62bd2dbdc4fefff2e9a91824081a" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" dependencies = [ "generic-array", "subtle", @@ -610,6 +639,15 @@ dependencies = [ "syn 2.0.105", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1429,6 +1467,7 @@ dependencies = [ "md5", "nix", "once_cell", + "os_pipe", "pest", "pest_derive", "pwhash", @@ -1455,6 +1494,7 @@ dependencies = [ "tokio-util", "tower", "tower-http", + "ureq", "utoipa", "utoipa-swagger-ui", "uzers", @@ -1719,6 +1759,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -1771,6 +1817,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -1883,6 +1939,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2010,6 +2072,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ringbuf" version = "0.3.3" @@ -2153,6 +2229,41 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2446,9 +2557,9 @@ dependencies = [ [[package]] name = "subtle" -version = "2.4.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -2569,6 +2680,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -2830,6 +2972,44 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.4" @@ -2841,6 +3021,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3018,6 +3204,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.0" @@ -3119,6 +3314,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3378,6 +3582,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 71910d4..8541261 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,8 @@ pest = "2.8.1" pest_derive = "2.8.1" dirs = "6.0.0" similar = { version = "2.7.0", default-features = false, features = ["text"] } +ureq = { version = "3", features = ["json"], optional = true } +os_pipe = { version = "1", optional = true } [features] # Default features include core compression engines and swagger UI @@ -102,6 +104,9 @@ mcp = ["dep:rmcp"] # Swagger UI feature swagger = ["dep:utoipa-swagger-ui"] +# Client feature (HTTP client for remote server) +client = ["dep:ureq", "dep:os_pipe"] + [dev-dependencies] tempfile = "3.3.0" rand = "0.8.5" diff --git a/src/args.rs b/src/args.rs index 5cd19da..d994a4b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -141,6 +141,16 @@ pub struct OptionsArgs { #[arg(help("Password hash for server authentication (requires --server)"))] pub server_password_hash: Option, + #[cfg(feature = "client")] + #[arg(long, env("KEEP_CLIENT_URL"), help_heading("Client Options"))] + #[arg(help("Remote keep server URL for client mode"))] + pub client_url: Option, + + #[cfg(feature = "client")] + #[arg(long, env("KEEP_CLIENT_PASSWORD"), help_heading("Client Options"))] + #[arg(help("Password for remote keep server authentication"))] + pub client_password: Option, + #[arg( long, help("Force output even when binary data would be sent to a TTY") diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..0143c22 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,310 @@ +use crate::services::error::CoreError; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use std::io::Read; + +/// Item information returned from the server API. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct ItemInfo { + pub id: i64, + pub ts: String, + pub size: Option, + pub compression: String, + pub tags: Vec, + pub metadata: HashMap, +} + +pub struct KeepClient { + base_url: String, + agent: ureq::Agent, + password: Option, +} + +impl KeepClient { + pub fn new(base_url: &str, password: Option) -> Result { + let base_url = base_url.trim_end_matches('/').to_string(); + let agent = ureq::Agent::new_with_defaults(); + Ok(Self { + base_url, + agent, + password, + }) + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn password(&self) -> Option<&String> { + self.password.as_ref() + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + fn handle_error(&self, result: Result) -> Result { + match result { + Ok(v) => Ok(v), + Err(ureq::Error::StatusCode(code)) => Err(CoreError::Other(anyhow::anyhow!( + "Server returned error: HTTP {}", + code + ))), + Err(e) => Err(CoreError::Other(anyhow::anyhow!("Request failed: {}", e))), + } + } + + pub fn get_json(&self, path: &str) -> Result { + let url = self.url(path); + let mut req = self.agent.get(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + let response = self.handle_error(req.call())?; + let body: T = self.handle_error(response.into_body().read_json())?; + Ok(body) + } + + pub fn get_json_with_query( + &self, + path: &str, + params: &[(&str, &str)], + ) -> Result { + let mut url = self.url(path); + if !params.is_empty() { + url.push('?'); + for (i, (key, value)) in params.iter().enumerate() { + if i > 0 { + url.push('&'); + } + url.push_str(&format!("{key}={value}")); + } + } + let mut req = self.agent.get(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + let response = self.handle_error(req.call())?; + let body: T = self.handle_error(response.into_body().read_json())?; + Ok(body) + } + + pub fn get_bytes(&self, path: &str) -> Result, CoreError> { + let url = self.url(path); + let mut req = self.agent.get(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + let response = self.handle_error(req.call())?; + let mut body = response.into_body(); + let bytes = body + .read_to_vec() + .map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?; + Ok(bytes) + } + + pub fn post_bytes( + &self, + path: &str, + body_bytes: &[u8], + params: &[(&str, &str)], + ) -> Result { + let mut cursor = std::io::Cursor::new(body_bytes); + self.post_stream(path, &mut cursor, params) + } + + /// Stream data from a reader to the server using chunked transfer encoding. + /// + /// The reader is consumed in chunks and sent to the server without buffering + /// the entire body in memory. This enables true streaming for large payloads. + pub fn post_stream( + &self, + path: &str, + body_reader: &mut dyn Read, + params: &[(&str, &str)], + ) -> Result { + let mut url = self.url(path); + if !params.is_empty() { + url.push('?'); + for (i, (key, value)) in params.iter().enumerate() { + if i > 0 { + url.push('&'); + } + url.push_str(&format!("{key}={value}")); + } + } + + let mut req = self.agent.post(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + req = req.header("Content-Type", "application/octet-stream"); + + let response = self.handle_error(req.send(ureq::SendBody::from_reader(body_reader)))?; + + #[derive(serde::Deserialize)] + struct ApiResponse { + data: Option, + error: Option, + } + + let api_response: ApiResponse = self.handle_error(response.into_body().read_json())?; + + if let Some(error) = api_response.error { + return Err(CoreError::Other(anyhow::anyhow!("Server error: {}", error))); + } + + api_response + .data + .ok_or_else(|| CoreError::Other(anyhow::anyhow!("No data in response"))) + } + + pub fn delete(&self, path: &str) -> Result<(), CoreError> { + let url = self.url(path); + let mut req = self.agent.delete(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + self.handle_error(req.call())?; + Ok(()) + } + + pub fn get_status(&self) -> Result { + self.get_json("/api/status") + } + + pub fn get_item_info(&self, id: i64) -> Result { + #[derive(serde::Deserialize)] + struct ApiResponse { + data: Option, + } + let response: ApiResponse = self.get_json(&format!("/api/item/{id}/info"))?; + response + .data + .ok_or_else(|| CoreError::Other(anyhow::anyhow!("Item not found"))) + } + + pub fn list_items( + &self, + tags: &[String], + order: &str, + start: u64, + count: u64, + ) -> Result, CoreError> { + #[derive(serde::Deserialize)] + struct ApiResponse { + data: Option>, + } + + let mut params: Vec<(String, String)> = Vec::new(); + params.push(("order".to_string(), order.to_string())); + params.push(("start".to_string(), start.to_string())); + params.push(("count".to_string(), count.to_string())); + if !tags.is_empty() { + params.push(("tags".to_string(), tags.join(","))); + } + + let param_refs: Vec<(&str, &str)> = params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + let response: ApiResponse = self.get_json_with_query("/api/item/", ¶m_refs)?; + Ok(response.data.unwrap_or_default()) + } + + pub fn save_item( + &self, + content: &[u8], + tags: &[String], + metadata: &HashMap, + compress: bool, + meta: bool, + ) -> Result { + let mut params: Vec<(String, String)> = Vec::new(); + if !tags.is_empty() { + params.push(("tags".to_string(), tags.join(","))); + } + if !metadata.is_empty() { + let meta_json = serde_json::to_string(metadata).map_err(|e| { + CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {}", e)) + })?; + params.push(("metadata".to_string(), meta_json)); + } + params.push(("compress".to_string(), compress.to_string())); + params.push(("meta".to_string(), meta.to_string())); + + let param_refs: Vec<(&str, &str)> = params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + self.post_bytes("/api/item/", content, ¶m_refs) + } + + pub fn delete_item(&self, id: i64) -> Result<(), CoreError> { + self.delete(&format!("/api/item/{id}")) + } + + /// Add metadata to an existing item. + pub fn post_metadata( + &self, + id: i64, + metadata: &HashMap, + ) -> Result<(), CoreError> { + let url = self.url(&format!("/api/item/{id}/meta")); + let mut req = self.agent.post(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + req = req.header("Content-Type", "application/json"); + + let body = serde_json::to_vec(metadata) + .map_err(|e| CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {e}")))?; + + let mut cursor = std::io::Cursor::new(body); + self.handle_error(req.send(ureq::SendBody::from_reader(&mut cursor)))?; + Ok(()) + } + + pub fn get_item_content_raw(&self, id: i64) -> Result<(Vec, String), CoreError> { + let url = format!( + "{}?decompress=false", + self.url(&format!("/api/item/{id}/content")) + ); + + let mut req = self.agent.get(&url); + if let Some(ref password) = self.password { + req = req.header("Authorization", &format!("Bearer {password}")); + } + + let response = self.handle_error(req.call())?; + + let compression = response + .headers() + .get("X-Keep-Compression") + .and_then(|v| v.to_str().ok()) + .unwrap_or("none") + .to_string(); + + let mut body = response.into_body(); + let bytes = body + .read_to_vec() + .map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?; + + Ok((bytes, compression)) + } + + pub fn diff_items(&self, id_a: i64, id_b: i64) -> Result, CoreError> { + #[derive(serde::Deserialize)] + struct ApiResponse { + data: Option>, + } + + let params = [("id_a", id_a.to_string()), ("id_b", id_b.to_string())]; + let param_refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + let response: ApiResponse = self.get_json_with_query("/api/diff", ¶m_refs)?; + Ok(response.data.unwrap_or_default()) + } +} diff --git a/src/config.rs b/src/config.rs index 411cd1f..7faee68 100644 --- a/src/config.rs +++ b/src/config.rs @@ -153,6 +153,12 @@ pub struct CompressionPluginConfig { pub name: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClientConfig { + pub url: Option, + pub password: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "server", derive(utoipa::ToSchema))] pub struct MetaPluginConfig { @@ -184,6 +190,12 @@ pub struct Settings { pub server: Option, pub compression_plugin: Option, pub meta_plugins: Option>, + pub client: Option, + // Non-serializable fields populated from CLI args + #[serde(skip)] + pub client_url: Option, + #[serde(skip)] + pub client_password: Option, } impl Settings { @@ -394,6 +406,21 @@ impl Settings { settings.dir = default_dir; } + // Populate client settings from CLI args and config + #[cfg(feature = "client")] + { + settings.client_url = args + .options + .client_url + .clone() + .or_else(|| settings.client.as_ref().and_then(|c| c.url.clone())); + settings.client_password = args + .options + .client_password + .clone() + .or_else(|| settings.client.as_ref().and_then(|c| c.password.clone())); + } + debug!("CONFIG: Final settings: {settings:?}"); Ok(settings) } diff --git a/src/lib.rs b/src/lib.rs index c7a5217..2f9c997 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,9 @@ pub mod meta_plugin; pub mod modes; pub mod services; +#[cfg(feature = "client")] +pub mod client; + // Re-export Args struct for library usage pub use args::Args; // Re-export PIPESIZE constant diff --git a/src/main.rs b/src/main.rs index c6463be..ad22cd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,6 +168,68 @@ fn main() -> Result<(), Error> { debug!("MAIN: mode: {mode:?}"); debug!("MAIN: settings: {settings:?}"); + // Parse filter chain early for better error reporting + let filter_chain = if let Some(filter_str) = &args.item.filters { + match keep::filter_plugin::parse_filter_string(filter_str) { + Ok(chain) => Some(chain), + Err(e) => { + cmd.error( + ErrorKind::InvalidValue, + format!("Invalid filter string: {e}"), + ) + .exit(); + } + } + } else { + None + }; + + // Check for client mode + #[cfg(feature = "client")] + { + if let Some(ref client_url) = settings.client_url { + let client = + keep::client::KeepClient::new(client_url, settings.client_password.clone())?; + + return match mode { + KeepModes::Save => { + let metadata = std::collections::HashMap::new(); + keep::modes::client::save::mode(&client, &mut cmd, &settings, tags, metadata) + } + KeepModes::Get => keep::modes::client::get::mode( + &client, + &mut cmd, + &settings, + ids, + tags, + filter_chain, + ), + KeepModes::List => { + keep::modes::client::list::mode(&client, &mut cmd, &settings, tags) + } + KeepModes::Delete => { + keep::modes::client::delete::mode(&client, &mut cmd, &settings, ids) + } + KeepModes::Info => { + keep::modes::client::info::mode(&client, &mut cmd, &settings, ids, tags) + } + KeepModes::Diff => { + keep::modes::client::diff::mode(&client, &mut cmd, &settings, ids) + } + KeepModes::Status => { + keep::modes::client::status::mode(&client, &mut cmd, &settings) + } + _ => { + cmd.error( + ErrorKind::InvalidValue, + format!("Mode {mode:?} is not supported in client mode"), + ) + .exit(); + } + }; + } + } + unsafe { libc::umask(0o077); } @@ -186,22 +248,6 @@ fn main() -> Result<(), Error> { // Initialize database let mut conn = db::open(db_path.clone())?; - // Parse filter chain early for better error reporting - let filter_chain = if let Some(filter_str) = &args.item.filters { - match keep::filter_plugin::parse_filter_string(filter_str) { - Ok(chain) => Some(chain), - Err(e) => { - cmd.error( - ErrorKind::InvalidValue, - format!("Invalid filter string: {e}"), - ) - .exit(); - } - } - } else { - None - }; - match mode { KeepModes::Save => { modes::save::mode_save(&mut cmd, &settings, ids, tags, &mut conn, data_path) diff --git a/src/modes/client/delete.rs b/src/modes/client/delete.rs new file mode 100644 index 0000000..747fc1a --- /dev/null +++ b/src/modes/client/delete.rs @@ -0,0 +1,21 @@ +use crate::client::KeepClient; +use clap::Command; +use log::debug; + +pub fn mode( + client: &KeepClient, + _cmd: &mut Command, + settings: &crate::config::Settings, + ids: &[i64], +) -> Result<(), anyhow::Error> { + debug!("CLIENT_DELETE: Deleting items via remote server"); + + for &id in ids { + client.delete_item(id)?; + if !settings.quiet { + eprintln!("Deleted item {id}"); + } + } + + Ok(()) +} diff --git a/src/modes/client/diff.rs b/src/modes/client/diff.rs new file mode 100644 index 0000000..9dba82e --- /dev/null +++ b/src/modes/client/diff.rs @@ -0,0 +1,24 @@ +use crate::client::KeepClient; +use clap::Command; +use log::debug; + +pub fn mode( + client: &KeepClient, + _cmd: &mut Command, + _settings: &crate::config::Settings, + ids: &[i64], +) -> Result<(), anyhow::Error> { + debug!("CLIENT_DIFF: Getting diff via remote server"); + + if ids.len() != 2 { + return Err(anyhow::anyhow!("Diff requires exactly 2 item IDs")); + } + + let diff_lines = client.diff_items(ids[0], ids[1])?; + + for line in &diff_lines { + println!("{line}"); + } + + Ok(()) +} diff --git a/src/modes/client/get.rs b/src/modes/client/get.rs new file mode 100644 index 0000000..6ba0695 --- /dev/null +++ b/src/modes/client/get.rs @@ -0,0 +1,95 @@ +use crate::client::KeepClient; +use crate::compression_engine::CompressionType; +use crate::filter_plugin::FilterChain; +use anyhow::Result; +use clap::Command; +use is_terminal::IsTerminal; +use log::debug; +use std::io::{Read, Write}; +use std::str::FromStr; + +pub fn mode( + client: &KeepClient, + _cmd: &mut Command, + settings: &crate::config::Settings, + ids: &[i64], + tags: &[String], + filter_chain: Option, +) -> Result<(), anyhow::Error> { + debug!("CLIENT_GET: Getting item via remote server"); + + // Find the item ID + let item_id = if !ids.is_empty() { + ids[0] + } else if !tags.is_empty() { + // Find item by tags + let items = client.list_items(tags, "newest", 0, 1)?; + if items.is_empty() { + return Err(anyhow::anyhow!("No items found matching tags: {:?}", tags)); + } + items[0].id + } else { + // Get latest item + let items = client.list_items(&[], "newest", 0, 1)?; + if items.is_empty() { + return Err(anyhow::anyhow!("No items found")); + } + items[0].id + }; + + // Get item info to determine compression type + let item_info = client.get_item_info(item_id)?; + + // Get raw content from server + let (raw_bytes, compression) = client.get_item_content_raw(item_id)?; + + // Check if binary content would be sent to TTY + let is_text = item_info + .metadata + .get("text") + .map(|v| v == "true") + .unwrap_or(false); + + if std::io::stdout().is_terminal() && !is_text && !settings.force { + // Check if content is binary + let sample_len = std::cmp::min(raw_bytes.len(), 8192); + if crate::common::is_binary::is_binary(&raw_bytes[..sample_len]) { + return Err(anyhow::anyhow!( + "Refusing to output binary data to a terminal. Use --force to override." + )); + } + } + + // Decompress locally + let compression_type = CompressionType::from_str(&compression).unwrap_or(CompressionType::None); + + let decompressed = match compression_type { + CompressionType::GZip => { + use flate2::read::GzDecoder; + let mut decoder = GzDecoder::new(&raw_bytes[..]); + let mut content = Vec::new(); + decoder.read_to_end(&mut content)?; + content + } + CompressionType::LZ4 => lz4_flex::decompress_size_prepended(&raw_bytes) + .map_err(|e| anyhow::anyhow!("LZ4 decompression failed: {}", e))?, + _ => raw_bytes, + }; + + // Apply filters if present + let output = if let Some(mut chain) = filter_chain { + let mut filtered = Vec::new(); + chain.filter(&mut &decompressed[..], &mut filtered)?; + filtered + } else { + decompressed + }; + + // Stream to stdout + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(&output)?; + stdout.flush()?; + + Ok(()) +} diff --git a/src/modes/client/info.rs b/src/modes/client/info.rs new file mode 100644 index 0000000..e9e7da0 --- /dev/null +++ b/src/modes/client/info.rs @@ -0,0 +1,65 @@ +use crate::client::KeepClient; +use crate::modes::common::{OutputFormat, format_size, settings_output_format}; +use clap::Command; +use log::debug; + +pub fn mode( + client: &KeepClient, + _cmd: &mut Command, + settings: &crate::config::Settings, + ids: &[i64], + tags: &[String], +) -> Result<(), anyhow::Error> { + debug!("CLIENT_INFO: Getting item info via remote server"); + + let output_format = settings_output_format(settings); + + // If tags provided, find matching item first + let item_ids: Vec = if !tags.is_empty() { + let items = client.list_items(tags, "newest", 0, 1)?; + if items.is_empty() { + return Err(anyhow::anyhow!("No items found matching tags: {:?}", tags)); + } + items.into_iter().map(|i| i.id).collect() + } else { + ids.to_vec() + }; + + for &id in &item_ids { + let item = client.get_item_info(id)?; + + match output_format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&item)?); + } + OutputFormat::Yaml => { + println!("{}", serde_yaml::to_string(&item)?); + } + OutputFormat::Table => { + use comfy_table::{Table, presets::UTF8_FULL}; + + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + + let size_str = item + .size + .map(|s| format_size(s as u64, settings.human_readable)) + .unwrap_or_else(|| "N/A".to_string()); + + table.add_row(vec!["ID".to_string(), item.id.to_string()]); + table.add_row(vec!["Time".to_string(), item.ts.clone()]); + table.add_row(vec!["Size".to_string(), size_str]); + table.add_row(vec!["Compression".to_string(), item.compression.clone()]); + table.add_row(vec!["Tags".to_string(), item.tags.join(", ")]); + + for (key, value) in &item.metadata { + table.add_row(vec![format!("Meta: {}", key), value.clone()]); + } + + println!("{table}"); + } + } + } + + Ok(()) +} diff --git a/src/modes/client/list.rs b/src/modes/client/list.rs new file mode 100644 index 0000000..fd60e16 --- /dev/null +++ b/src/modes/client/list.rs @@ -0,0 +1,55 @@ +use crate::client::KeepClient; +use crate::modes::common::{OutputFormat, format_size, settings_output_format}; +use clap::Command; +use log::debug; + +pub fn mode( + client: &KeepClient, + _cmd: &mut Command, + settings: &crate::config::Settings, + tags: &[String], +) -> Result<(), anyhow::Error> { + debug!("CLIENT_LIST: Listing items via remote server"); + + let items = client.list_items(tags, "newest", 0, 100)?; + + let output_format = settings_output_format(settings); + + match output_format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&items)?); + } + OutputFormat::Yaml => { + println!("{}", serde_yaml::to_string(&items)?); + } + OutputFormat::Table => { + use comfy_table::{Table, presets::UTF8_FULL}; + + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + + // Header + let headers = ["ID", "Time", "Size", "Compression", "Tags"]; + table.set_header(headers.iter().map(|h| h.to_string()).collect::>()); + + for item in &items { + let size_str = item + .size + .map(|s| format_size(s as u64, settings.human_readable)) + .unwrap_or_default(); + + table.add_row(vec![ + item.id.to_string(), + item.ts.clone(), + size_str, + item.compression.clone(), + item.tags.join(", "), + ]); + } + + println!("{table}"); + } + } + + Ok(()) +} diff --git a/src/modes/client/mod.rs b/src/modes/client/mod.rs new file mode 100644 index 0000000..bfff5b6 --- /dev/null +++ b/src/modes/client/mod.rs @@ -0,0 +1,7 @@ +pub mod delete; +pub mod diff; +pub mod get; +pub mod info; +pub mod list; +pub mod save; +pub mod status; diff --git a/src/modes/client/save.rs b/src/modes/client/save.rs new file mode 100644 index 0000000..4fa0f32 --- /dev/null +++ b/src/modes/client/save.rs @@ -0,0 +1,171 @@ +use crate::client::{ItemInfo, KeepClient}; +use crate::compression_engine::CompressionType; +use crate::config::Settings; +use crate::modes::common::settings_compression_type; +use anyhow::Result; +use clap::Command; +use is_terminal::IsTerminal; +use log::debug; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; + +/// Streaming save mode for client. +/// +/// Uses three threads for true streaming with constant memory: +/// - Reader thread: reads stdin, tees to stdout, computes SHA-256, +/// compresses data, writes to OS pipe +/// - Pipe: zero-copy transfer of compressed bytes between threads +/// - Streamer thread: reads from pipe, streams to server via chunked HTTP +/// +/// Memory usage is O(PIPESIZE) regardless of data size. +pub fn mode( + client: &KeepClient, + cmd: &mut Command, + settings: &Settings, + tags: &mut Vec, + metadata: HashMap, +) -> Result<(), anyhow::Error> { + debug!("CLIENT_SAVE: Saving item via remote server (streaming)"); + + if tags.is_empty() { + tags.push("none".to_string()); + } + + // Determine compression type from settings + let compression_type = settings_compression_type(cmd, settings); + let server_compress = matches!(compression_type, CompressionType::None); + + // Create OS pipe for streaming compressed bytes between threads + let (pipe_reader, pipe_writer) = os_pipe::pipe()?; + + // Shared state for reader thread results + let shared = Arc::new(Mutex::new((0u64, String::new()))); + let shared_reader = Arc::clone(&shared); + + // Reader thread: stdin → tee(stdout) → hash → compress → pipe + let compression_type_clone = compression_type.clone(); + let reader_handle = std::thread::spawn(move || -> Result<(u64, String)> { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut stdin_lock = stdin.lock(); + let mut stdout_lock = stdout.lock(); + + let mut hasher = Sha256::new(); + let mut total_bytes = 0u64; + let mut buffer = [0u8; 8192]; + + // Wrap pipe writer with appropriate compression + let mut compressor: Box = match compression_type_clone { + CompressionType::GZip => { + use flate2::Compression; + use flate2::write::GzEncoder; + Box::new(GzEncoder::new(pipe_writer, Compression::default())) + } + CompressionType::LZ4 => Box::new(lz4_flex::frame::FrameEncoder::new(pipe_writer)), + _ => Box::new(pipe_writer), + }; + + loop { + let n = stdin_lock.read(&mut buffer)?; + if n == 0 { + break; + } + + // Tee to stdout + stdout_lock.write_all(&buffer[..n])?; + + // Update hash + hasher.update(&buffer[..n]); + total_bytes += n as u64; + + // Compress and write to pipe + compressor.write_all(&buffer[..n])?; + } + + // Finalize compression (flushes any buffered compressed data) + drop(compressor); + + // Pipe writer is now dropped (inside compressor), signaling EOF to streamer + + let digest = format!("{:x}", hasher.finalize()); + + // Set shared state for main thread + let mut shared = shared_reader.lock().unwrap(); + *shared = (total_bytes, digest.clone()); + + Ok((total_bytes, digest)) + }); + + // Streamer thread: reads compressed bytes from pipe → POST to server + let client_url = client.base_url().to_string(); + let client_password = client.password().cloned(); + let tags_clone = tags.clone(); + + let streamer_handle = std::thread::spawn(move || -> Result { + let streaming_client = KeepClient::new(&client_url, client_password)?; + let params = [ + ("compress".to_string(), server_compress.to_string()), + ("meta".to_string(), "false".to_string()), + ("tags".to_string(), tags_clone.join(",")), + ]; + let param_refs: Vec<(&str, &str)> = params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + let mut reader: Box = Box::new(pipe_reader); + let item_info = streaming_client.post_stream("/api/item/", &mut reader, ¶m_refs)?; + Ok(item_info) + }); + + // Wait for streaming to complete, capture item info + let item_info = streamer_handle + .join() + .map_err(|e| anyhow::anyhow!("Streamer thread panicked: {:?}", e))??; + + // Wait for reader thread (should complete quickly after pipe is drained) + reader_handle + .join() + .map_err(|e| anyhow::anyhow!("Reader thread panicked: {:?}", e))??; + + // Read results from shared state + let (uncompressed_size, digest) = { + let shared = shared.lock().unwrap(); + shared.clone() + }; + + // Build local metadata and send to server + let mut local_metadata = metadata; + local_metadata.insert("digest_sha256".to_string(), digest); + local_metadata.insert( + "uncompressed_size".to_string(), + uncompressed_size.to_string(), + ); + + // Add hostname + if let Ok(hostname) = gethostname::gethostname().into_string() { + local_metadata.insert("hostname".to_string(), hostname.clone()); + let short = hostname.split('.').next().unwrap_or(&hostname).to_string(); + local_metadata.insert("hostname_short".to_string(), short); + } + + // Send metadata to server + if !local_metadata.is_empty() { + client.post_metadata(item_info.id, &local_metadata)?; + } + + // Print status to stderr + if !settings.quiet { + if std::io::stderr().is_terminal() { + eprintln!("KEEP: New item (streaming) tags: {}", tags.join(" ")); + } else { + eprintln!("KEEP: New item (streaming) tags: {tags:?}"); + } + } + + debug!("CLIENT_SAVE: Streaming complete, {uncompressed_size} bytes uncompressed"); + + Ok(()) +} diff --git a/src/modes/client/status.rs b/src/modes/client/status.rs new file mode 100644 index 0000000..fa80874 --- /dev/null +++ b/src/modes/client/status.rs @@ -0,0 +1,33 @@ +use crate::client::KeepClient; +use crate::modes::common::OutputFormat; +use crate::modes::common::settings_output_format; +use clap::Command; +use log::debug; + +pub fn mode( + client: &KeepClient, + _cmd: &mut Command, + settings: &crate::config::Settings, +) -> Result<(), anyhow::Error> { + debug!("CLIENT_STATUS: Getting status from remote server"); + + let status = client.get_status()?; + + let output_format = settings_output_format(settings); + + match output_format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&status)?); + } + OutputFormat::Yaml => { + println!("{}", serde_yaml::to_string(&status)?); + } + OutputFormat::Table => { + println!("Remote Server Status"); + println!("===================="); + println!("{}", serde_json::to_string_pretty(&status)?); + } + } + + Ok(()) +} diff --git a/src/modes/mod.rs b/src/modes/mod.rs index 58458f4..57c26a3 100644 --- a/src/modes/mod.rs +++ b/src/modes/mod.rs @@ -1,6 +1,9 @@ #[cfg(feature = "server")] pub mod server; +#[cfg(feature = "client")] +pub mod client; + /// Common utilities for all modes, including column types and output formatting. pub mod common;