feat: add client mode with streaming support
Add client mode enabling the keep CLI to connect to a remote keep server over HTTP. Local plugins (compression, meta, filters) run on the client; the server stores/retrieves binary blobs. Architecture: - Client save uses 3-thread streaming pipeline: reader thread (stdin → tee/stdout → hash → compress), OS pipe, streamer thread (pipe → chunked HTTP POST). Memory usage is O(PIPESIZE) regardless of data size. - Server accepts compress=false, meta=false, decompress=false query params for granular control of server-side processing. - Streaming body handling on server via async channel → sync reader bridge (ChannelReader). Key additions: - src/client.rs: KeepClient with post_stream() for chunked upload - src/modes/client/: save, get, list, info, delete, diff, status - --client-url / KEEP_CLIENT_URL configuration - --client-password / KEEP_CLIENT_PASSWORD for auth - os_pipe dependency for zero-copy pipe streaming Co-Authored-By: andrew/openrouter/hunter-alpha <noreply@opencode.ai>
This commit is contained in:
218
Cargo.lock
generated
218
Cargo.lock
generated
@@ -486,6 +486,35 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -557,9 +586,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-mac"
|
name = "crypto-mac"
|
||||||
version = "0.10.1"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bff07008ec701e8028e2ceb8f83f0e4274ee62bd2dbdc4fefff2e9a91824081a"
|
checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -610,6 +639,15 @@ dependencies = [
|
|||||||
"syn 2.0.105",
|
"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]]
|
[[package]]
|
||||||
name = "derive_arbitrary"
|
name = "derive_arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -1429,6 +1467,7 @@ dependencies = [
|
|||||||
"md5",
|
"md5",
|
||||||
"nix",
|
"nix",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"os_pipe",
|
||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
"pwhash",
|
"pwhash",
|
||||||
@@ -1455,6 +1494,7 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
"ureq",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
"uzers",
|
"uzers",
|
||||||
@@ -1719,6 +1759,12 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1771,6 +1817,16 @@ dependencies = [
|
|||||||
"hashbrown 0.14.5",
|
"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]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@@ -1883,6 +1939,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -2010,6 +2072,20 @@ version = "0.8.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
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]]
|
[[package]]
|
||||||
name = "ringbuf"
|
name = "ringbuf"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -2153,6 +2229,41 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -2446,9 +2557,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.4.1"
|
version = "2.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
@@ -2569,6 +2680,37 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tiny-keccak"
|
name = "tiny-keccak"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -2830,6 +2972,44 @@ version = "0.2.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
@@ -2841,6 +3021,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3018,6 +3204,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "which"
|
name = "which"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -3119,6 +3314,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -3378,6 +3582,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ pest = "2.8.1"
|
|||||||
pest_derive = "2.8.1"
|
pest_derive = "2.8.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
similar = { version = "2.7.0", default-features = false, features = ["text"] }
|
similar = { version = "2.7.0", default-features = false, features = ["text"] }
|
||||||
|
ureq = { version = "3", features = ["json"], optional = true }
|
||||||
|
os_pipe = { version = "1", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Default features include core compression engines and swagger UI
|
# Default features include core compression engines and swagger UI
|
||||||
@@ -102,6 +104,9 @@ mcp = ["dep:rmcp"]
|
|||||||
# Swagger UI feature
|
# Swagger UI feature
|
||||||
swagger = ["dep:utoipa-swagger-ui"]
|
swagger = ["dep:utoipa-swagger-ui"]
|
||||||
|
|
||||||
|
# Client feature (HTTP client for remote server)
|
||||||
|
client = ["dep:ureq", "dep:os_pipe"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.3.0"
|
tempfile = "3.3.0"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
|||||||
10
src/args.rs
10
src/args.rs
@@ -141,6 +141,16 @@ pub struct OptionsArgs {
|
|||||||
#[arg(help("Password hash for server authentication (requires --server)"))]
|
#[arg(help("Password hash for server authentication (requires --server)"))]
|
||||||
pub server_password_hash: Option<String>,
|
pub server_password_hash: Option<String>,
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
help("Force output even when binary data would be sent to a TTY")
|
help("Force output even when binary data would be sent to a TTY")
|
||||||
|
|||||||
310
src/client.rs
Normal file
310
src/client.rs
Normal file
@@ -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<i64>,
|
||||||
|
pub compression: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub metadata: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KeepClient {
|
||||||
|
base_url: String,
|
||||||
|
agent: ureq::Agent,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeepClient {
|
||||||
|
pub fn new(base_url: &str, password: Option<String>) -> Result<Self, CoreError> {
|
||||||
|
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<T>(&self, result: Result<T, ureq::Error>) -> Result<T, CoreError> {
|
||||||
|
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<T: DeserializeOwned>(&self, path: &str) -> Result<T, 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 body: T = self.handle_error(response.into_body().read_json())?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_json_with_query<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
params: &[(&str, &str)],
|
||||||
|
) -> Result<T, CoreError> {
|
||||||
|
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<Vec<u8>, 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<ItemInfo, CoreError> {
|
||||||
|
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<ItemInfo, CoreError> {
|
||||||
|
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<ItemInfo>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<serde_json::Value, CoreError> {
|
||||||
|
self.get_json("/api/status")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_item_info(&self, id: i64) -> Result<ItemInfo, CoreError> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ApiResponse {
|
||||||
|
data: Option<ItemInfo>,
|
||||||
|
}
|
||||||
|
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<Vec<ItemInfo>, CoreError> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ApiResponse {
|
||||||
|
data: Option<Vec<ItemInfo>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, String>,
|
||||||
|
compress: bool,
|
||||||
|
meta: bool,
|
||||||
|
) -> Result<ItemInfo, CoreError> {
|
||||||
|
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<String, String>,
|
||||||
|
) -> 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<u8>, 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<Vec<String>, CoreError> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ApiResponse {
|
||||||
|
data: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,6 +153,12 @@ pub struct CompressionPluginConfig {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ClientConfig {
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
|
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
|
||||||
pub struct MetaPluginConfig {
|
pub struct MetaPluginConfig {
|
||||||
@@ -184,6 +190,12 @@ pub struct Settings {
|
|||||||
pub server: Option<ServerConfig>,
|
pub server: Option<ServerConfig>,
|
||||||
pub compression_plugin: Option<CompressionPluginConfig>,
|
pub compression_plugin: Option<CompressionPluginConfig>,
|
||||||
pub meta_plugins: Option<Vec<MetaPluginConfig>>,
|
pub meta_plugins: Option<Vec<MetaPluginConfig>>,
|
||||||
|
pub client: Option<ClientConfig>,
|
||||||
|
// Non-serializable fields populated from CLI args
|
||||||
|
#[serde(skip)]
|
||||||
|
pub client_url: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub client_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
@@ -394,6 +406,21 @@ impl Settings {
|
|||||||
settings.dir = default_dir;
|
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:?}");
|
debug!("CONFIG: Final settings: {settings:?}");
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ pub mod meta_plugin;
|
|||||||
pub mod modes;
|
pub mod modes;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
// Re-export Args struct for library usage
|
// Re-export Args struct for library usage
|
||||||
pub use args::Args;
|
pub use args::Args;
|
||||||
// Re-export PIPESIZE constant
|
// Re-export PIPESIZE constant
|
||||||
|
|||||||
78
src/main.rs
78
src/main.rs
@@ -168,6 +168,68 @@ fn main() -> Result<(), Error> {
|
|||||||
debug!("MAIN: mode: {mode:?}");
|
debug!("MAIN: mode: {mode:?}");
|
||||||
debug!("MAIN: settings: {settings:?}");
|
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 {
|
unsafe {
|
||||||
libc::umask(0o077);
|
libc::umask(0o077);
|
||||||
}
|
}
|
||||||
@@ -186,22 +248,6 @@ fn main() -> Result<(), Error> {
|
|||||||
// Initialize database
|
// Initialize database
|
||||||
let mut conn = db::open(db_path.clone())?;
|
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 {
|
match mode {
|
||||||
KeepModes::Save => {
|
KeepModes::Save => {
|
||||||
modes::save::mode_save(&mut cmd, &settings, ids, tags, &mut conn, data_path)
|
modes::save::mode_save(&mut cmd, &settings, ids, tags, &mut conn, data_path)
|
||||||
|
|||||||
21
src/modes/client/delete.rs
Normal file
21
src/modes/client/delete.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
24
src/modes/client/diff.rs
Normal file
24
src/modes/client/diff.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
95
src/modes/client/get.rs
Normal file
95
src/modes/client/get.rs
Normal file
@@ -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<FilterChain>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
65
src/modes/client/info.rs
Normal file
65
src/modes/client/info.rs
Normal file
@@ -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<i64> = 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(())
|
||||||
|
}
|
||||||
55
src/modes/client/list.rs
Normal file
55
src/modes/client/list.rs
Normal file
@@ -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::<Vec<_>>());
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
7
src/modes/client/mod.rs
Normal file
7
src/modes/client/mod.rs
Normal file
@@ -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;
|
||||||
171
src/modes/client/save.rs
Normal file
171
src/modes/client/save.rs
Normal file
@@ -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<String>,
|
||||||
|
metadata: HashMap<String, String>,
|
||||||
|
) -> 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<dyn Write> = 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<ItemInfo> {
|
||||||
|
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<dyn Read> = 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(())
|
||||||
|
}
|
||||||
33
src/modes/client/status.rs
Normal file
33
src/modes/client/status.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
/// Common utilities for all modes, including column types and output formatting.
|
/// Common utilities for all modes, including column types and output formatting.
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user