diff --git a/Cargo.lock b/Cargo.lock index ef07e50..0c4452f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -228,9 +263,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "chdig" version = "26.1.1" dependencies = [ + "aes-gcm", "anyhow", "arboard", "backtrace", + "base64", "chrono", "chrono-tz", "clap", @@ -250,6 +287,7 @@ dependencies = [ "percent-encoding", "pretty_assertions", "quick-xml", + "rand", "ratatui", "regex", "semver", @@ -298,6 +336,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.54" @@ -432,6 +480,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -523,6 +580,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cursive" version = "0.21.1" @@ -940,6 +1016,16 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -973,6 +1059,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1182,6 +1278,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" @@ -1554,6 +1659,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1687,6 +1798,18 @@ dependencies = [ "time", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1702,6 +1825,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1752,6 +1884,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -1760,6 +1904,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "ratatui" @@ -2321,6 +2468,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2356,6 +2509,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index dcb2caf..73332b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,10 @@ tokio = { version = "*", default-features = false, features = ["macros"] } flamelens = { git = "https://github.com/ys-l/flamelens", branch = "main", default-features = false } ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] } crossterm = { version = "0.28.1", features = ["use-dev-tty"] } +# Sharing +aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"] } +rand = { version = "0.8", default-features = false, features = ["std", "std_rng"] } +base64 = { version = "0.22", default-features = false, features = ["std"] } [dev-dependencies] pretty_assertions = { version= "*", default-features = false, features = ["alloc"] } diff --git a/src/interpreter/flamegraph.rs b/src/interpreter/flamegraph.rs index 7641190..ea97c28 100644 --- a/src/interpreter/flamegraph.rs +++ b/src/interpreter/flamegraph.rs @@ -1,7 +1,7 @@ use crate::interpreter::clickhouse::Columns; +use crate::pastila; use crate::utils::open_url_command; use anyhow::{Error, Result}; -use clickhouse_rs::{Block, Options, Pool}; use crossterm::event::{self, Event as CrosstermEvent, KeyEventKind}; use flamelens::app::{App, AppResult}; use flamelens::flame::FlameGraph; @@ -9,200 +9,9 @@ use flamelens::handler::handle_key_events; use flamelens::ui; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use regex::Regex; use std::io; -use std::str::FromStr; -use url::Url; use urlencoding::encode; -/// ClickHouse's SipHash-2-4 implementation (128-bit version) -/// See https://github.com/ClickHouse/ClickHouse/pull/46065 for details -struct ClickHouseSipHash { - v0: u64, - v1: u64, - v2: u64, - v3: u64, - cnt: u64, - current_word: u64, - current_bytes_len: usize, -} - -impl ClickHouseSipHash { - fn new() -> Self { - Self { - v0: 0x736f6d6570736575u64, - v1: 0x646f72616e646f6du64, - v2: 0x6c7967656e657261u64, - v3: 0x7465646279746573u64, - cnt: 0, - current_word: 0, - current_bytes_len: 0, - } - } - - #[inline] - fn sipround(&mut self) { - self.v0 = self.v0.wrapping_add(self.v1); - self.v1 = self.v1.rotate_left(13); - self.v1 ^= self.v0; - self.v0 = self.v0.rotate_left(32); - - self.v2 = self.v2.wrapping_add(self.v3); - self.v3 = self.v3.rotate_left(16); - self.v3 ^= self.v2; - - self.v0 = self.v0.wrapping_add(self.v3); - self.v3 = self.v3.rotate_left(21); - self.v3 ^= self.v0; - - self.v2 = self.v2.wrapping_add(self.v1); - self.v1 = self.v1.rotate_left(17); - self.v1 ^= self.v2; - self.v2 = self.v2.rotate_left(32); - } - - fn write(&mut self, data: &[u8]) { - for &byte in data { - let byte_idx = self.current_bytes_len; - self.current_word |= (byte as u64) << (byte_idx * 8); - self.current_bytes_len += 1; - self.cnt += 1; - - if self.current_bytes_len == 8 { - self.v3 ^= self.current_word; - self.sipround(); - self.sipround(); - self.v0 ^= self.current_word; - - self.current_word = 0; - self.current_bytes_len = 0; - } - } - } - - fn finish128(mut self) -> u128 { - // Set the last byte to cnt % 256 - let cnt_byte = (self.cnt % 256) as u8; - self.current_word |= (cnt_byte as u64) << 56; - - self.v3 ^= self.current_word; - self.sipround(); - self.sipround(); - self.v0 ^= self.current_word; - - // ClickHouse uses 0xff instead of 0xee - self.v2 ^= 0xff; - self.sipround(); - self.sipround(); - self.sipround(); - self.sipround(); - - // Combine v0, v1, v2, v3 into 128-bit result - let low = self.v0 ^ self.v1; - let high = self.v2 ^ self.v3; - - ((high as u128) << 64) | (low as u128) - } -} - -fn calculate_hash(text: &str) -> String { - let mut hasher = ClickHouseSipHash::new(); - hasher.write(text.as_bytes()); - let hash = hasher.finish128(); - format!("{:032x}", hash.swap_bytes()) -} - -fn get_fingerprint(text: &str) -> String { - let re = Regex::new(r"\b\w{4,100}\b").unwrap(); - let words: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); - - if words.len() < 3 { - return "ffffffff".to_string(); - } - - let mut min_hash: Option = None; - - for i in 0..words.len().saturating_sub(2) { - let triplet = format!("{} {} {}", words[i], words[i + 1], words[i + 2]); - let mut hasher = ClickHouseSipHash::new(); - hasher.write(triplet.as_bytes()); - let hash_value = hasher.finish128(); - - min_hash = Some(min_hash.map_or(hash_value, |current| current.min(hash_value))); - } - - let full_hash = match min_hash { - Some(hash) => format!("{:032x}", hash.swap_bytes()), - None => "ffffffffffffffffffffffffffffffff".to_string(), - }; - full_hash[..8].to_string() -} - -async fn upload_to_pastila( - content: &str, - pastila_clickhouse_host: &str, - pastila_url: &str, -) -> Result { - let fingerprint_hex = get_fingerprint(content); - let hash_hex = calculate_hash(content); - - { - let url = { - let http_url = Url::parse(pastila_clickhouse_host)?; - let host = http_url - .host_str() - .ok_or_else(|| Error::msg("No host in pastila_clickhouse_host"))?; - - let user = if !http_url.username().is_empty() { - http_url.username().to_string() - } else { - http_url - .query_pairs() - .find(|(k, _)| k == "user") - .map(|(_, v)| v.to_string()) - .unwrap_or_else(|| "default".to_string()) - }; - - let secure = http_url.scheme() == "https"; - let port = if secure { 9440 } else { 9000 }; - - format!( - "tcp://{}@{}:{}/?secure={}&connection_timeout=5s", - user, host, port, secure - ) - }; - let options = Options::from_str(&url)?; - let pool = Pool::new(options); - let mut client = pool.get_handle().await?; - - let block = Block::new() - .column("fingerprint_hex", vec![fingerprint_hex.as_str()]) - .column("hash_hex", vec![hash_hex.as_str()]) - .column("content", vec![content]) - .column("is_encrypted", vec![0_u8]); - client.insert("paste.data", block).await?; - } - - log::info!( - "Uploaded {} bytes to {}", - content.len(), - pastila_clickhouse_host - ); - - let pastila_url = pastila_url.trim_end_matches('/'); - let pastila_page_url = format!("{}/?{}/{}", pastila_url, fingerprint_hex, hash_hex); - log::info!("Pastila URL: {}", pastila_page_url); - - let select_query = format!( - "SELECT content FROM data_view(fingerprint = '{}', hash = '{}') FORMAT TabSeparatedRaw", - fingerprint_hex, hash_hex - ); - let clickhouse_url = format!("{}&query={}", pastila_clickhouse_host, &select_query); - log::info!("Pastila ClickHouse URL: {}", clickhouse_url); - - Ok(clickhouse_url) -} - pub fn show(block: Columns) -> AppResult<()> { let data = block .rows() @@ -284,7 +93,8 @@ pub async fn open_in_speedscope( return Err(Error::msg("Flamegraph is empty")); } - let pastila_url = upload_to_pastila(&data, pastila_clickhouse_host, pastila_url).await?; + let pastila_url = + pastila::upload_to_pastila(&data, pastila_clickhouse_host, pastila_url).await?; let url = format!( "https://www.speedscope.app/#profileURL={}", diff --git a/src/interpreter/worker.rs b/src/interpreter/worker.rs index 708943a..c169471 100644 --- a/src/interpreter/worker.rs +++ b/src/interpreter/worker.rs @@ -5,6 +5,7 @@ use crate::{ clickhouse::{Columns, TextLogArguments, TraceType}, flamegraph, }, + pastila, utils::{highlight_sql, open_graph_in_browser}, view::{self, Navigation}, }; @@ -73,6 +74,8 @@ pub enum Event { TableParts(String, String), // (database, table) AsynchronousInserts(String, String), + // (content to share) + ShareLogs(String), } impl Event { @@ -100,6 +103,7 @@ impl Event { Event::BackgroundSchedulePoolLogs(..) => "BackgroundSchedulePoolLogs".to_string(), Event::TableParts(..) => "TableParts".to_string(), Event::AsynchronousInserts(..) => "AsynchronousInserts".to_string(), + Event::ShareLogs(..) => "ShareLogs".to_string(), } } } @@ -712,6 +716,27 @@ async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } + Event::ShareLogs(content) => { + let url = + pastila::upload_logs_encrypted(&content, &pastila_clickhouse_host, &pastila_url) + .await?; + + let url_clone = url.clone(); + cb_sink + .send(Box::new(move |siv: &mut cursive::Cursive| { + siv.pop_layer(); + siv.add_layer( + views::Dialog::text(format!("Logs shared (encrypted):\n\n{}", url)) + .title("Share Complete") + .button("Close", |siv| { + siv.pop_layer(); + }), + ); + })) + .map_err(|_| anyhow!("Cannot send message to UI"))?; + + crate::utils::open_url_command(&url_clone).status()?; + } } return Ok(()); diff --git a/src/lib.rs b/src/lib.rs index 3a7003f..2065a8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod actions; mod common; mod interpreter; +mod pastila; mod utils; mod view; diff --git a/src/pastila.rs b/src/pastila.rs new file mode 100644 index 0000000..b133668 --- /dev/null +++ b/src/pastila.rs @@ -0,0 +1,249 @@ +use aes_gcm::{ + Aes128Gcm, KeyInit, Nonce, + aead::{Aead, generic_array::GenericArray}, +}; +use anyhow::{Result, anyhow}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use clickhouse_rs::{Block, Options, Pool}; +use rand::RngCore; +use regex::Regex; +use std::str::FromStr; +use url::Url; + +/// ClickHouse's SipHash-2-4 implementation (128-bit version) +/// See https://github.com/ClickHouse/ClickHouse/pull/46065 for details +pub struct ClickHouseSipHash { + v0: u64, + v1: u64, + v2: u64, + v3: u64, + cnt: u64, + current_word: u64, + current_bytes_len: usize, +} + +impl ClickHouseSipHash { + pub fn new() -> Self { + Self { + v0: 0x736f6d6570736575u64, + v1: 0x646f72616e646f6du64, + v2: 0x6c7967656e657261u64, + v3: 0x7465646279746573u64, + cnt: 0, + current_word: 0, + current_bytes_len: 0, + } + } + + #[inline] + fn sipround(&mut self) { + self.v0 = self.v0.wrapping_add(self.v1); + self.v1 = self.v1.rotate_left(13); + self.v1 ^= self.v0; + self.v0 = self.v0.rotate_left(32); + + self.v2 = self.v2.wrapping_add(self.v3); + self.v3 = self.v3.rotate_left(16); + self.v3 ^= self.v2; + + self.v0 = self.v0.wrapping_add(self.v3); + self.v3 = self.v3.rotate_left(21); + self.v3 ^= self.v0; + + self.v2 = self.v2.wrapping_add(self.v1); + self.v1 = self.v1.rotate_left(17); + self.v1 ^= self.v2; + self.v2 = self.v2.rotate_left(32); + } + + pub fn write(&mut self, data: &[u8]) { + for &byte in data { + let byte_idx = self.current_bytes_len; + self.current_word |= (byte as u64) << (byte_idx * 8); + self.current_bytes_len += 1; + self.cnt += 1; + + if self.current_bytes_len == 8 { + self.v3 ^= self.current_word; + self.sipround(); + self.sipround(); + self.v0 ^= self.current_word; + + self.current_word = 0; + self.current_bytes_len = 0; + } + } + } + + pub fn finish128(mut self) -> u128 { + let cnt_byte = (self.cnt % 256) as u8; + self.current_word |= (cnt_byte as u64) << 56; + + self.v3 ^= self.current_word; + self.sipround(); + self.sipround(); + self.v0 ^= self.current_word; + + self.v2 ^= 0xff; + self.sipround(); + self.sipround(); + self.sipround(); + self.sipround(); + + let low = self.v0 ^ self.v1; + let high = self.v2 ^ self.v3; + + ((high as u128) << 64) | (low as u128) + } +} + +pub fn calculate_hash(text: &str) -> String { + let mut hasher = ClickHouseSipHash::new(); + hasher.write(text.as_bytes()); + let hash = hasher.finish128(); + format!("{:032x}", hash.swap_bytes()) +} + +pub fn get_fingerprint(text: &str) -> String { + let re = Regex::new(r"\b\w{4,100}\b").unwrap(); + let words: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); + + if words.len() < 3 { + return "ffffffff".to_string(); + } + + let mut min_hash: Option = None; + + for i in 0..words.len().saturating_sub(2) { + let triplet = format!("{} {} {}", words[i], words[i + 1], words[i + 2]); + let mut hasher = ClickHouseSipHash::new(); + hasher.write(triplet.as_bytes()); + let hash_value = hasher.finish128(); + + min_hash = Some(min_hash.map_or(hash_value, |current| current.min(hash_value))); + } + + let full_hash = match min_hash { + Some(hash) => format!("{:032x}", hash.swap_bytes()), + None => "ffffffffffffffffffffffffffffffff".to_string(), + }; + full_hash[..8].to_string() +} + +fn encrypt_content(content: &str, key: &[u8; 16]) -> Result { + let cipher = Aes128Gcm::new(GenericArray::from_slice(key)); + let nonce = Nonce::from_slice(&key[..12]); + + let ciphertext = cipher + .encrypt(nonce, content.as_bytes()) + .map_err(|e| anyhow!("Encryption failed: {}", e))?; + + Ok(BASE64.encode(&ciphertext)) +} + +async fn get_pastila_client(pastila_clickhouse_host: &str) -> Result { + let url = { + let http_url = Url::parse(pastila_clickhouse_host)?; + let host = http_url + .host_str() + .ok_or_else(|| anyhow!("No host in pastila_clickhouse_host"))?; + + let user = if !http_url.username().is_empty() { + http_url.username().to_string() + } else { + http_url + .query_pairs() + .find(|(k, _)| k == "user") + .map(|(_, v)| v.to_string()) + .unwrap_or_else(|| "default".to_string()) + }; + + let secure = http_url.scheme() == "https"; + let port = if secure { 9440 } else { 9000 }; + + format!( + "tcp://{}@{}:{}/?secure={}&connection_timeout=5s", + user, host, port, secure + ) + }; + let options = Options::from_str(&url)?; + let pool = Pool::new(options); + let client = pool.get_handle().await?; + Ok(client) +} + +pub async fn upload_to_pastila( + content: &str, + pastila_clickhouse_host: &str, + pastila_url: &str, +) -> Result { + let fingerprint_hex = get_fingerprint(content); + let hash_hex = calculate_hash(content); + + { + let mut client = get_pastila_client(pastila_clickhouse_host).await?; + let block = Block::new() + .column("fingerprint_hex", vec![fingerprint_hex.as_str()]) + .column("hash_hex", vec![hash_hex.as_str()]) + .column("content", vec![content]) + .column("is_encrypted", vec![0_u8]); + client.insert("paste.data", block).await?; + } + + log::info!( + "Uploaded {} bytes to {}", + content.len(), + pastila_clickhouse_host + ); + + let pastila_url = pastila_url.trim_end_matches('/'); + let pastila_page_url = format!("{}/?{}/{}", pastila_url, fingerprint_hex, hash_hex); + log::info!("Pastila URL: {}", pastila_page_url); + + let select_query = format!( + "SELECT content FROM data_view(fingerprint = '{}', hash = '{}') FORMAT TabSeparatedRaw", + fingerprint_hex, hash_hex + ); + let clickhouse_url = format!("{}&query={}", pastila_clickhouse_host, &select_query); + log::info!("Pastila ClickHouse URL: {}", clickhouse_url); + + Ok(clickhouse_url) +} + +pub async fn upload_logs_encrypted( + content: &str, + pastila_clickhouse_host: &str, + pastila_url: &str, +) -> Result { + let mut key = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut key); + let encrypted = encrypt_content(content, &key)?; + + let fingerprint_hex = get_fingerprint(&encrypted); + let hash_hex = calculate_hash(&encrypted); + + { + let mut client = get_pastila_client(pastila_clickhouse_host).await?; + let block = Block::new() + .column("fingerprint_hex", vec![fingerprint_hex.as_str()]) + .column("hash_hex", vec![hash_hex.as_str()]) + .column("content", vec![encrypted.as_str()]) + .column("is_encrypted", vec![1_u8]); + client.insert("paste.data", block).await?; + } + + log::info!( + "Uploaded {} bytes to {} (encrypted)", + content.len(), + pastila_clickhouse_host + ); + + let pastila_url = pastila_url.trim_end_matches('/'); + let key_fragment = format!("#{}", BASE64.encode(key)); + let pastila_page_url = format!( + "{}/?{}/{}{}GCM", + pastila_url, fingerprint_hex, hash_hex, key_fragment + ); + + Ok(pastila_page_url) +} diff --git a/src/view/log_view.rs b/src/view/log_view.rs index d598aa0..c2d5be4 100644 --- a/src/view/log_view.rs +++ b/src/view/log_view.rs @@ -1108,6 +1108,54 @@ impl LogView { ); }; + let show_share_prompt = |siv: &mut Cursive| { + let context = siv.user_data::().unwrap().clone(); + + let dialog = Dialog::text("Share logs to pastila.nl with end-to-end encryption?") + .title("Share Logs") + .button("Share (encrypted)", move |siv: &mut Cursive| { + let context = context.clone(); + siv.pop_layer(); + + let content = + siv.call_on_name("logs", |base: &mut LogViewBase| -> Result { + let mut buffer = Vec::new(); + base.write_plain_text(&mut buffer)?; + Ok(String::from_utf8(buffer)?) + }); + + let content = match content { + Some(Ok(c)) => c, + Some(Err(e)) => { + siv.add_layer(Dialog::info(format!("Error reading logs: {}", e))); + return; + } + None => { + siv.add_layer(Dialog::info("Error: Could not access log content")); + return; + } + }; + + if content.trim().is_empty() { + siv.add_layer(Dialog::info("No logs to share")); + return; + } + + siv.add_layer(Dialog::text("Uploading logs...").title("Please wait")); + + context + .lock() + .unwrap() + .worker + .send(false, crate::interpreter::WorkerEvent::ShareLogs(content)); + }) + .button("Cancel", |siv: &mut Cursive| { + siv.pop_layer(); + }); + + siv.add_layer(dialog); + }; + let toggle_filter_mode_and_prompt = |siv: &mut Cursive| { siv.call_on_name("logs", |base: &mut LogViewBase| { if base.filter_mode { @@ -1220,6 +1268,11 @@ impl LogView { show_save_prompt, )))); }) + .on_event_inner('S', move |_, _| { + return Some(EventResult::Consumed(Some(Callback::from_fn( + show_share_prompt, + )))); + }) .on_event_inner(Event::CtrlChar('f'), move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( toggle_filter_mode_and_prompt,