diff --git a/README.md b/README.md index 887b274..629f9c8 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,22 @@ Telegram Drive leverages the Telegram API to allow you to upload, organize, and * **Drag & Drop**: Intuitive drag-and-drop upload and file management. * **Thumbnail Previews**: Inline thumbnails for images and media files. * **Folder Management**: Create "Folders" (private Telegram Channels) to organize content. +* **Optional Encrypted Vault**: Store files in an end-to-end encrypted vault backed by a private Telegram channel. * **Privacy Focused**: API keys and data stay local. No third-party servers. * **Cross-Platform**: Native apps for macOS (Intel/ARM), Windows, and Linux. +### Encrypted Vault Mode + +Telegram Drive can also run in **Encrypted Vault** mode. After logging in, you can choose between the normal Saved Messages drive and a local-password-protected vault. + +Vault files are encrypted on your device before upload using a key derived from your vault password. Telegram only receives encrypted vault blobs and encrypted manifest snapshots, stored in a private channel named `TelegramVault`. File names, folder records, and file contents are kept inside the encrypted vault manifest instead of being stored as normal Telegram channel folders. + +Normal Drive remains the default path and keeps the existing Saved Messages/channel behavior unchanged. The first Vault Mode version stores vault configuration locally on the device where the vault was created, so fresh-device vault restore/import is not yet included. Keep your vault password safe; it is required to unlock encrypted files and cannot be recovered by the app. + +| Storage Mode Selection | Vault Unlock | +|------------------------|--------------| +| ![Storage Mode Selection](screenshots/ModeSelection.png) | ![Vault Unlock](screenshots/VaultUnlock.png) | + ## Screenshots | Dashboard | File Preview | diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index ca5fe3a..da1a3f2 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -34,6 +34,29 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.10.0", + "bytes", + "derive_more 2.1.1", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.11.2" @@ -207,6 +230,16 @@ 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" @@ -309,13 +342,16 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "app" -version = "1.1.6" +version = "1.1.7" dependencies = [ "actix-cors", + "actix-files", "actix-rt", "actix-web", + "argon2", "async-stream", "base64 0.21.7", + "chacha20poly1305", "chrono", "env_logger", "futures", @@ -337,7 +373,10 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "tokio", + "uuid", + "zeroize", ] [[package]] @@ -349,6 +388,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -549,6 +600,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -564,6 +621,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -770,6 +836,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.43" @@ -792,6 +882,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -944,6 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2080,6 +2172,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2147,7 +2245,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -3138,6 +3236,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -3265,6 +3369,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -3490,6 +3605,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -5514,6 +5640,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[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 = "untrusted" version = "0.9.0" @@ -5575,6 +5711,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index c1c3945..354816b 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -37,10 +37,15 @@ log = "0.4" env_logger = "0.11" tauri-plugin-updater = "2.9.0" actix-web = "4" +actix-files = "0.6" actix-cors = "0.7" futures = "0.3" async-stream = "0.3" actix-rt = "2" tauri-plugin-process = "2.3.1" rand = "0.8" - +argon2 = "0.5" +chacha20poly1305 = "0.10" +zeroize = "1" +uuid = { version = "1", features = ["v4"] } +tempfile = "3" diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 80e2033..97f6ad4 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,10 +1,10 @@ +use grammers_client::types::{LoginToken, PasswordToken}; +use grammers_client::Client; use std::sync::Arc; use tokio::sync::Mutex; -use grammers_client::{Client}; -use grammers_client::types::{LoginToken, PasswordToken}; /// Tracks the lifecycle of the Telegram connection -/// +/// /// IMPORTANT: The `runner_shutdown` field is critical for preventing stack overflow. /// When reconnecting, we MUST shutdown the old runner before spawning a new one. /// Without this, runner tasks accumulate and exhaust the thread stack. @@ -24,14 +24,16 @@ pub struct TelegramState { pub mod auth; pub mod fs; -pub mod preview; -pub mod utils; pub mod network; +pub mod preview; pub mod streaming; +pub mod utils; +pub mod vault; pub use auth::*; pub use fs::*; -pub use preview::*; -pub use utils::*; pub use network::*; +pub use preview::*; pub use streaming::*; +pub use utils::*; +pub use vault::*; diff --git a/app/src-tauri/src/commands/preview.rs b/app/src-tauri/src/commands/preview.rs index b8d84bf..3f729d8 100644 --- a/app/src-tauri/src/commands/preview.rs +++ b/app/src-tauri/src/commands/preview.rs @@ -162,13 +162,15 @@ pub async fn cmd_get_preview( pub async fn cmd_clean_cache( app_handle: tauri::AppHandle, ) -> Result<(), String> { - let cache_dir = app_handle + let base_cache_dir = app_handle .path() .app_cache_dir() - .map_err(|e: tauri::Error| e.to_string())? - .join("previews"); - if cache_dir.exists() { - let _ = std::fs::remove_dir_all(cache_dir); + .map_err(|e: tauri::Error| e.to_string())?; + for child in ["previews", "vault"] { + let cache_dir = base_cache_dir.join(child); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(cache_dir); + } } Ok(()) } @@ -271,4 +273,4 @@ pub async fn cmd_get_thumbnail( } Ok("".to_string()) -} \ No newline at end of file +} diff --git a/app/src-tauri/src/commands/vault.rs b/app/src-tauri/src/commands/vault.rs new file mode 100644 index 0000000..4d5350d --- /dev/null +++ b/app/src-tauri/src/commands/vault.rs @@ -0,0 +1,756 @@ +use std::path::Path; + +use base64::{engine::general_purpose, Engine as _}; +use grammers_client::Client; +use tauri::{Emitter, State}; +use uuid::Uuid; +use zeroize::Zeroize; + +use crate::bandwidth::BandwidthManager; +use crate::commands::TelegramState; +use crate::models::{FileMetadata, FolderMetadata}; +use crate::vault::cache::decrypt_file_to_cache; +use crate::vault::crypto::{random_key, unwrap_key, wrap_key}; +use crate::vault::format::{ + decrypt_file_to_path, encrypt_file_to_path, file_key_aad, write_encrypted_manifest, +}; +use crate::vault::manifest::{FileRecord, FolderRecord, VaultManifest}; +use crate::vault::state::{ + config_exists, load_config, make_config, manifest_path, random_positive_id, save_config, + save_local_manifest, unlock_from_disk, vault_cache_dir, UnlockedVault, VaultRuntime, + VaultStatus, +}; +use crate::vault::{format, storage}; + +#[derive(Clone, serde::Serialize)] +struct ProgressPayload { + id: String, + percent: u8, +} + +async fn connected_client(state: &State<'_, TelegramState>) -> Result { + state + .client + .lock() + .await + .clone() + .ok_or_else(|| "Telegram client is not connected".to_string()) +} + +pub async fn persist_manifest( + app_handle: &tauri::AppHandle, + client: &Client, + vault: &mut UnlockedVault, +) -> Result<(), String> { + let encrypted = format::encrypt_manifest(&vault.master_key, &vault.manifest)?; + let path = manifest_path(app_handle)?; + write_encrypted_manifest(&path, &encrypted)?; + let message_id = storage::upload_object( + client, + vault.config.bucket_id, + &path, + format!( + "tdv1 manifest {} {}", + vault.config.vault_id, vault.manifest.generation + ), + ) + .await?; + vault.config.latest_manifest_message_id = Some(message_id); + save_config(app_handle, &vault.config) +} + +fn path_file_name(path: &str) -> Result { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .ok_or_else(|| "Selected path has no valid filename".to_string()) +} + +fn file_ext(name: &str) -> Option { + Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .filter(|ext| !ext.is_empty()) + .map(|ext| ext.to_string()) +} + +fn folder_to_metadata(folder: &FolderRecord) -> FileMetadata { + FileMetadata { + id: folder.id, + folder_id: folder.parent_id, + name: folder.name.clone(), + size: 0, + mime_type: None, + file_ext: None, + created_at: folder.created_at.clone(), + icon_type: "folder".to_string(), + } +} + +fn file_to_metadata(file: &FileRecord) -> FileMetadata { + FileMetadata { + id: file.id, + folder_id: file.folder_id, + name: file.name.clone(), + size: file.size, + mime_type: file.mime_type.clone(), + file_ext: file.file_ext.clone(), + created_at: file.created_at.clone(), + icon_type: "file".to_string(), + } +} + +fn ensure_unique_id(manifest: &VaultManifest) -> i64 { + loop { + let id = random_positive_id(); + if !manifest.files.iter().any(|file| file.id == id) + && !manifest.folders.iter().any(|folder| folder.id == id) + { + return id; + } + } +} + +fn image_mime_from_name(name: &str) -> Option<&'static str> { + let ext = Path::new(name) + .extension() + .and_then(|ext| ext.to_str())? + .to_ascii_lowercase(); + + match ext.as_str() { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "bmp" => Some("image/bmp"), + "svg" => Some("image/svg+xml"), + _ => None, + } +} + +fn image_mime_from_bytes(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Some("image/jpeg"); + } + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some("image/png"); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Some("image/gif"); + } + if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Some("image/webp"); + } + if bytes.starts_with(b"BM") { + return Some("image/bmp"); + } + + let sample_len = bytes.len().min(512); + let sample = String::from_utf8_lossy(&bytes[..sample_len]).to_ascii_lowercase(); + if sample.contains(" Result { + if let Some(name_mime) = image_mime_from_name(&cached.name) { + let bytes = + std::fs::read(&cached.path).map_err(|e| format!("Failed to read preview: {}", e))?; + let mime = image_mime_from_bytes(&bytes).unwrap_or(name_mime); + let b64 = general_purpose::STANDARD.encode(&bytes); + return Ok(format!("data:{};base64,{}", mime, b64)); + } + + Ok(cached.path.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn cmd_vault_status( + app_handle: tauri::AppHandle, + runtime: State<'_, VaultRuntime>, +) -> Result { + let guard = runtime.inner.lock().await; + Ok(VaultStatus { + configured: config_exists(&app_handle), + unlocked: guard.is_some(), + vault_id: guard.as_ref().map(|vault| vault.config.vault_id.clone()), + generation: guard.as_ref().map(|vault| vault.manifest.generation), + }) +} + +#[tauri::command] +pub async fn cmd_vault_create( + app_handle: tauri::AppHandle, + password: String, + telegram_state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + if password.len() < 10 { + return Err("Vault password must be at least 10 characters.".to_string()); + } + if config_exists(&app_handle) { + return Err("A vault is already configured on this device.".to_string()); + } + + let client = connected_client(&telegram_state).await?; + let vault_id = Uuid::new_v4().to_string(); + let bucket_id = storage::create_ciphertext_bucket(&client, &vault_id).await?; + let (config, master_key) = make_config(&password, vault_id.clone(), bucket_id)?; + let manifest = VaultManifest::new(vault_id, bucket_id); + let mut vault = UnlockedVault { + config, + master_key, + manifest, + }; + + save_local_manifest(&app_handle, &vault)?; + persist_manifest(&app_handle, &client, &mut vault).await?; + + let status = VaultStatus { + configured: true, + unlocked: true, + vault_id: Some(vault.config.vault_id.clone()), + generation: Some(vault.manifest.generation), + }; + *runtime.inner.lock().await = Some(vault); + Ok(status) +} + +#[tauri::command] +pub async fn cmd_vault_unlock( + app_handle: tauri::AppHandle, + password: String, + telegram_state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + if !config_exists(&app_handle) { + return Err("No vault is configured on this device.".to_string()); + } + + let local_manifest = manifest_path(&app_handle)?; + if !local_manifest.exists() { + let config = load_config(&app_handle)?; + let manifest_message_id = config.latest_manifest_message_id.ok_or_else(|| { + "Local manifest missing and no remote manifest is recorded".to_string() + })?; + let client = connected_client(&telegram_state).await?; + storage::download_object( + &client, + config.bucket_id, + manifest_message_id, + &local_manifest, + ) + .await?; + } + + let vault = unlock_from_disk(&app_handle, &password)?; + let status = VaultStatus { + configured: true, + unlocked: true, + vault_id: Some(vault.config.vault_id.clone()), + generation: Some(vault.manifest.generation), + }; + *runtime.inner.lock().await = Some(vault); + Ok(status) +} + +#[tauri::command] +pub async fn cmd_vault_lock( + app_handle: tauri::AppHandle, + runtime: State<'_, VaultRuntime>, +) -> Result { + *runtime.inner.lock().await = None; + if let Ok(cache_dir) = vault_cache_dir(&app_handle) { + let _ = std::fs::remove_dir_all(cache_dir); + } + Ok(true) +} + +#[tauri::command] +pub async fn cmd_vault_create_folder( + name: String, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Folder name cannot be empty".to_string()); + } + + let client = connected_client(&state).await?; + let mut guard = runtime.inner.lock().await; + let vault = guard + .as_mut() + .ok_or_else(|| "Vault is locked. Unlock it before creating folders.".to_string())?; + + let folder = FolderRecord { + id: ensure_unique_id(&vault.manifest), + parent_id: None, + name: trimmed.to_string(), + created_at: VaultManifest::now(), + }; + vault.manifest.folders.push(folder.clone()); + vault.manifest.touch_generation(); + persist_manifest(&app_handle, &client, vault).await?; + + Ok(FolderMetadata { + id: folder.id, + parent_id: folder.parent_id, + name: folder.name, + }) +} + +#[tauri::command] +pub async fn cmd_vault_delete_folder( + folder_id: i64, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + let client = connected_client(&state).await?; + let mut guard = runtime.inner.lock().await; + let vault = guard + .as_mut() + .ok_or_else(|| "Vault is locked. Unlock it before deleting folders.".to_string())?; + + if vault + .manifest + .files + .iter() + .any(|file| file.folder_id == Some(folder_id)) + || vault + .manifest + .folders + .iter() + .any(|folder| folder.parent_id == Some(folder_id)) + { + return Err("Folder must be empty before it can be deleted.".to_string()); + } + + let before = vault.manifest.folders.len(); + vault + .manifest + .folders + .retain(|folder| folder.id != folder_id); + if vault.manifest.folders.len() == before { + return Err("Folder not found".to_string()); + } + + vault.manifest.touch_generation(); + persist_manifest(&app_handle, &client, vault).await?; + Ok(true) +} + +#[tauri::command] +pub async fn cmd_vault_upload_file( + path: String, + folder_id: Option, + transfer_id: Option, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, + bw_state: State<'_, BandwidthManager>, +) -> Result { + let source_path = Path::new(&path); + let metadata = std::fs::metadata(source_path).map_err(|e| e.to_string())?; + if !metadata.is_file() { + return Err("Only regular files can be uploaded".to_string()); + } + bw_state.can_transfer(metadata.len())?; + + let tid = transfer_id.unwrap_or_default(); + if !tid.is_empty() { + let _ = app_handle.emit( + "upload-progress", + ProgressPayload { + id: tid.clone(), + percent: 0, + }, + ); + } + + let client = connected_client(&state).await?; + let cache_dir = vault_cache_dir(&app_handle)?; + let encrypted_temp = tempfile::Builder::new() + .prefix("tdv1-upload-") + .suffix(".blob") + .tempfile_in(cache_dir) + .map_err(|e| format!("Failed to create encrypted temp file: {}", e))?; + let encrypted_path = encrypted_temp.path().to_path_buf(); + + let mut guard = runtime.inner.lock().await; + let vault = guard + .as_mut() + .ok_or_else(|| "Vault is locked. Unlock it before uploading files.".to_string())?; + if !vault.manifest.folder_exists(folder_id) { + return Err("Target folder not found".to_string()); + } + + let file_id = ensure_unique_id(&vault.manifest); + let mut file_key = random_key(); + let stats = encrypt_file_to_path( + source_path, + &encrypted_path, + &vault.config.vault_id, + file_id, + &file_key, + )?; + + let blob_message_id = storage::upload_object( + &client, + vault.config.bucket_id, + &encrypted_path, + format!("tdv1 blob {} {}", vault.config.vault_id, file_id), + ) + .await?; + + let wrapped_file_key = wrap_key( + &vault.master_key, + &file_key, + &file_key_aad(&vault.config.vault_id, file_id), + )?; + file_key.zeroize(); + + let now = VaultManifest::now(); + let name = path_file_name(&path)?; + let record = FileRecord { + id: file_id, + folder_id, + name: name.clone(), + size: stats.plaintext_size, + mime_type: None, + file_ext: file_ext(&name), + created_at: now.clone(), + modified_at: now, + blob_message_id, + ciphertext_size: stats.ciphertext_size, + chunk_size: stats.chunk_size, + chunk_count: stats.chunk_count, + wrapped_file_key, + }; + + vault.manifest.files.push(record); + vault.manifest.touch_generation(); + persist_manifest(&app_handle, &client, vault).await?; + bw_state.add_up(stats.ciphertext_size); + + if !tid.is_empty() { + let _ = app_handle.emit( + "upload-progress", + ProgressPayload { + id: tid, + percent: 100, + }, + ); + } + + Ok("Encrypted file uploaded successfully".to_string()) +} + +#[tauri::command] +pub async fn cmd_vault_delete_file( + message_id: i64, + folder_id: Option, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + let client = connected_client(&state).await?; + let mut guard = runtime.inner.lock().await; + let vault = guard + .as_mut() + .ok_or_else(|| "Vault is locked. Unlock it before deleting files.".to_string())?; + + let Some(index) = vault + .manifest + .files + .iter() + .position(|file| file.id == message_id && file.folder_id == folder_id) + else { + return Err("File not found".to_string()); + }; + + let record = vault.manifest.files.remove(index); + vault.manifest.touch_generation(); + persist_manifest(&app_handle, &client, vault).await?; + let _ = storage::delete_object(&client, vault.config.bucket_id, record.blob_message_id).await; + Ok(true) +} + +#[tauri::command] +pub async fn cmd_vault_download_file( + message_id: i64, + save_path: String, + folder_id: Option, + transfer_id: Option, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, + bw_state: State<'_, BandwidthManager>, +) -> Result { + let tid = transfer_id.unwrap_or_default(); + if !tid.is_empty() { + let _ = app_handle.emit( + "download-progress", + ProgressPayload { + id: tid.clone(), + percent: 0, + }, + ); + } + + let client = connected_client(&state).await?; + let cache_dir = vault_cache_dir(&app_handle)?; + let encrypted_temp = tempfile::Builder::new() + .prefix("tdv1-download-") + .suffix(".blob") + .tempfile_in(cache_dir) + .map_err(|e| format!("Failed to create ciphertext temp file: {}", e))?; + let encrypted_path = encrypted_temp.path().to_path_buf(); + + let guard = runtime.inner.lock().await; + let vault = guard + .as_ref() + .ok_or_else(|| "Vault is locked. Unlock it before downloading files.".to_string())?; + let record = vault + .manifest + .files + .iter() + .find(|file| file.id == message_id && file.folder_id == folder_id) + .ok_or_else(|| "File not found".to_string())? + .clone(); + + bw_state.can_transfer(record.ciphertext_size)?; + storage::download_object( + &client, + vault.config.bucket_id, + record.blob_message_id, + &encrypted_path, + ) + .await?; + + let mut file_key = unwrap_key( + &vault.master_key, + &record.wrapped_file_key, + &file_key_aad(&vault.config.vault_id, record.id), + )?; + decrypt_file_to_path( + &encrypted_path, + Path::new(&save_path), + &vault.config.vault_id, + record.id, + &file_key, + )?; + file_key.zeroize(); + bw_state.add_down(record.ciphertext_size); + + if !tid.is_empty() { + let _ = app_handle.emit( + "download-progress", + ProgressPayload { + id: tid, + percent: 100, + }, + ); + } + + Ok("Encrypted file downloaded successfully".to_string()) +} + +#[tauri::command] +pub async fn cmd_vault_move_files( + message_ids: Vec, + source_folder_id: Option, + target_folder_id: Option, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + if source_folder_id == target_folder_id { + return Ok(true); + } + + let client = connected_client(&state).await?; + let mut guard = runtime.inner.lock().await; + let vault = guard + .as_mut() + .ok_or_else(|| "Vault is locked. Unlock it before moving files.".to_string())?; + if !vault.manifest.folder_exists(target_folder_id) { + return Err("Target folder not found".to_string()); + } + + let mut moved = 0usize; + for id in message_ids { + if let Some(file) = vault + .manifest + .files + .iter_mut() + .find(|file| file.id == id && file.folder_id == source_folder_id) + { + file.folder_id = target_folder_id; + file.modified_at = VaultManifest::now(); + moved += 1; + } + } + + if moved > 0 { + vault.manifest.touch_generation(); + persist_manifest(&app_handle, &client, vault).await?; + } + Ok(true) +} + +#[tauri::command] +pub async fn cmd_vault_get_files( + folder_id: Option, + runtime: State<'_, VaultRuntime>, +) -> Result, String> { + let guard = runtime.inner.lock().await; + let vault = guard + .as_ref() + .ok_or_else(|| "Vault is locked. Unlock it before listing files.".to_string())?; + + let mut entries = Vec::new(); + entries.extend( + vault + .manifest + .folders + .iter() + .filter(|folder| folder.parent_id == folder_id) + .map(folder_to_metadata), + ); + entries.extend( + vault + .manifest + .files + .iter() + .filter(|file| file.folder_id == folder_id) + .map(file_to_metadata), + ); + Ok(entries) +} + +#[tauri::command] +pub async fn cmd_vault_search_global( + query: String, + runtime: State<'_, VaultRuntime>, +) -> Result, String> { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return Ok(Vec::new()); + } + + let guard = runtime.inner.lock().await; + let vault = guard + .as_ref() + .ok_or_else(|| "Vault is locked. Unlock it before searching files.".to_string())?; + + let mut results: Vec = vault + .manifest + .folders + .iter() + .filter(|folder| folder.name.to_lowercase().contains(&query)) + .map(folder_to_metadata) + .collect(); + results.extend( + vault + .manifest + .files + .iter() + .filter(|file| file.name.to_lowercase().contains(&query)) + .map(file_to_metadata), + ); + Ok(results) +} + +#[tauri::command] +pub async fn cmd_vault_scan_folders( + runtime: State<'_, VaultRuntime>, +) -> Result, String> { + let guard = runtime.inner.lock().await; + let vault = guard + .as_ref() + .ok_or_else(|| "Vault is locked. Unlock it before syncing folders.".to_string())?; + + Ok(vault + .manifest + .folders + .iter() + .map(|folder| FolderMetadata { + id: folder.id, + parent_id: folder.parent_id, + name: folder.name.clone(), + }) + .collect()) +} + +#[tauri::command] +pub async fn cmd_vault_get_preview( + message_id: i64, + folder_id: Option, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + let client = connected_client(&state).await?; + let cache_dir = vault_cache_dir(&app_handle)?; + let cached = decrypt_file_to_cache( + &client, + runtime.inner(), + &cache_dir, + message_id, + folder_id, + "previews", + ) + .await?; + cached_preview_response(cached) +} + +#[tauri::command] +pub async fn cmd_vault_get_thumbnail( + message_id: i64, + folder_id: Option, + app_handle: tauri::AppHandle, + state: State<'_, TelegramState>, + runtime: State<'_, VaultRuntime>, +) -> Result { + let client = connected_client(&state).await?; + let cache_dir = vault_cache_dir(&app_handle)?; + let cached = decrypt_file_to_cache( + &client, + runtime.inner(), + &cache_dir, + message_id, + folder_id, + "thumbnails", + ) + .await?; + cached_preview_response(cached) +} + +#[cfg(test)] +mod tests { + use super::{image_mime_from_bytes, image_mime_from_name}; + + #[test] + fn detects_common_image_mime_types() { + assert_eq!(image_mime_from_name("photo.JPG"), Some("image/jpeg")); + assert_eq!(image_mime_from_name("graphic.png"), Some("image/png")); + assert_eq!(image_mime_from_name("archive.bin"), None); + assert_eq!( + image_mime_from_bytes(&[0xff, 0xd8, 0xff, 0x00]), + Some("image/jpeg") + ); + assert_eq!( + image_mime_from_bytes(b"\x89PNG\r\n\x1a\nrest"), + Some("image/png") + ); + assert_eq!( + image_mime_from_bytes(b""), + Some("image/svg+xml") + ); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index a476644..5e9ce21 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,14 +1,15 @@ pub mod models; -pub mod commands; pub mod bandwidth; +pub mod commands; +pub mod vault; -use tauri::Manager; -use tokio::sync::Mutex; -use std::sync::Arc; -use commands::TelegramState; use commands::streaming::StreamToken; +use commands::TelegramState; use rand::Rng; +use std::sync::Arc; +use tauri::Manager; +use tokio::sync::Mutex; pub mod server; @@ -28,6 +29,7 @@ pub fn run() { env_logger::init(); let stream_token = generate_stream_token(); + let vault_runtime = vault::VaultRuntime::default(); // Shared handle for stopping the Actix server during shutdown let server_handle: Arc>> = @@ -53,17 +55,32 @@ pub fn run() { runner_count: Arc::new(std::sync::atomic::AtomicU32::new(0)), }); app.manage(bandwidth::BandwidthManager::new(app.handle())); + app.manage(vault_runtime.clone()); app.manage(StreamToken(stream_token.clone())); app.manage(ActixServerHandle(server_handle_for_setup.clone())); - + // Start Streaming Server on dedicated thread (Actix needs its own runtime) let state = Arc::new(app.state::().inner().clone()); + let vault_runtime_for_server = vault_runtime.clone(); + let cache_dir_for_server = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to resolve app cache directory: {}", e))? + .join("vault"); let token_for_server = stream_token.clone(); let handle_for_thread = server_handle_for_setup.clone(); std::thread::spawn(move || { let sys = actix_rt::System::new(); sys.block_on(async move { - match server::start_server(state, 14200, token_for_server).await { + match server::start_server( + state, + vault_runtime_for_server, + cache_dir_for_server, + 14200, + token_for_server, + ) + .await + { Ok(server) => { // Store the handle so RunEvent::Exit can stop it *handle_for_thread.lock().unwrap() = Some(server.handle()); @@ -74,7 +91,7 @@ pub fn run() { } }); }); - + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -100,6 +117,21 @@ pub fn run() { commands::cmd_clean_cache, commands::cmd_get_thumbnail, commands::cmd_get_stream_token, + commands::cmd_vault_status, + commands::cmd_vault_create, + commands::cmd_vault_unlock, + commands::cmd_vault_lock, + commands::cmd_vault_get_files, + commands::cmd_vault_upload_file, + commands::cmd_vault_delete_file, + commands::cmd_vault_download_file, + commands::cmd_vault_move_files, + commands::cmd_vault_create_folder, + commands::cmd_vault_delete_folder, + commands::cmd_vault_scan_folders, + commands::cmd_vault_search_global, + commands::cmd_vault_get_preview, + commands::cmd_vault_get_thumbnail, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/app/src-tauri/src/server.rs b/app/src-tauri/src/server.rs index 8e58d14..f6b880a 100644 --- a/app/src-tauri/src/server.rs +++ b/app/src-tauri/src/server.rs @@ -1,11 +1,15 @@ -use actix_web::{get, web, App, HttpServer, HttpResponse, Responder}; -use actix_cors::Cors; -use crate::commands::TelegramState; +use std::path::PathBuf; +use std::sync::Arc; + use crate::commands::utils::resolve_peer; +use crate::commands::TelegramState; +use crate::vault::cache::decrypt_file_to_cache; +use crate::vault::state::VaultRuntime; +use actix_cors::Cors; +use actix_files::NamedFile; +use actix_web::{get, web, App, HttpRequest, HttpResponse, HttpServer}; use grammers_client::types::Media; -use std::sync::Arc; - /// Holds the per-session streaming token for Actix validation pub struct StreamTokenData { pub token: String, @@ -14,53 +18,72 @@ pub struct StreamTokenData { #[derive(serde::Deserialize)] struct StreamQuery { token: Option, + mode: Option, } #[get("/stream/{folder_id}/{message_id}")] async fn stream_media( - path: web::Path<(String, i32)>, + req: HttpRequest, + path: web::Path<(String, i64)>, query: web::Query, data: web::Data>, token_data: web::Data, -) -> impl Responder { + runtime: web::Data, + cache_dir: web::Data, +) -> actix_web::Result { // Validate session token match &query.token { - Some(t) if t == &token_data.token => {}, - _ => return HttpResponse::Forbidden().body("Invalid or missing stream token"), + Some(t) if t == &token_data.token => {} + _ => return Ok(HttpResponse::Forbidden().body("Invalid or missing stream token")), } let (folder_id_str, message_id) = path.into_inner(); - + // Parse folder ID let folder_id = if folder_id_str == "me" || folder_id_str == "home" || folder_id_str == "null" { None } else { match folder_id_str.parse::() { Ok(id) => Some(id), - Err(_) => return HttpResponse::BadRequest().body("Invalid folder ID"), + Err(_) => return Ok(HttpResponse::BadRequest().body("Invalid folder ID")), } }; - let client_opt = { - data.client.lock().await.clone() - }; + let client_opt = { data.client.lock().await.clone() }; if let Some(client) = client_opt { + if query.mode.as_deref() == Some("vault") { + let cached = decrypt_file_to_cache( + &client, + runtime.get_ref(), + cache_dir.get_ref(), + message_id, + folder_id, + "streams", + ) + .await + .map_err(actix_web::error::ErrorInternalServerError)?; + let file = NamedFile::open_async(cached.path) + .await + .map_err(actix_web::error::ErrorInternalServerError)?; + return Ok(file.into_response(&req)); + } + match resolve_peer(&client, folder_id).await { Ok(peer) => { // Try to fetch message efficiently - match client.get_messages_by_id(peer, &[message_id]).await { + match client.get_messages_by_id(peer, &[message_id as i32]).await { Ok(messages) => { if let Some(Some(msg)) = messages.first() { if let Some(media) = msg.media() { let size = match &media { Media::Document(d) => d.size(), - Media::Photo(_) => 0, + Media::Photo(_) => 0, _ => 0, }; - + let mime = mime_type_from_media(&media); - + // Create chunk-streaming response let mut download_iter = client.iter_download(&media); let stream = async_stream::stream! { @@ -74,39 +97,51 @@ async fn stream_media( } } }; - - return HttpResponse::Ok() - .insert_header(("Content-Type", mime)) + + return Ok(HttpResponse::Ok() + .insert_header(("Content-Type", mime)) .insert_header(("Content-Length", size.to_string())) .insert_header(("Cache-Control", "private, max-age=120")) - .streaming(stream); + .streaming(stream)); } } - HttpResponse::NotFound().body("Message or media not found") - }, - Err(e) => HttpResponse::InternalServerError().body(format!("Failed to fetch message: {}", e)), - } - }, - Err(e) => HttpResponse::BadRequest().body(format!("Peer resolution failed: {}", e)), + Ok(HttpResponse::NotFound().body("Message or media not found")) + } + Err(e) => Ok(HttpResponse::InternalServerError() + .body(format!("Failed to fetch message: {}", e))), + } + } + Err(e) => Ok(HttpResponse::BadRequest().body(format!("Peer resolution failed: {}", e))), } } else { - HttpResponse::ServiceUnavailable().body("Telegram client not connected") + Ok(HttpResponse::ServiceUnavailable().body("Telegram client not connected")) } } fn mime_type_from_media(media: &Media) -> String { match media { - Media::Document(d) => d.mime_type().unwrap_or("application/octet-stream").to_string(), + Media::Document(d) => d + .mime_type() + .unwrap_or("application/octet-stream") + .to_string(), _ => "application/octet-stream".to_string(), } } -pub async fn start_server(state: Arc, port: u16, token: String) -> std::io::Result { +pub async fn start_server( + state: Arc, + runtime: VaultRuntime, + cache_dir: PathBuf, + port: u16, + token: String, +) -> std::io::Result { let state_data = web::Data::new(state); let token_data = web::Data::new(StreamTokenData { token }); - + let runtime_data = web::Data::new(runtime); + let cache_dir_data = web::Data::new(cache_dir); + log::info!("Starting Streaming Server on port {}", port); - + let server = HttpServer::new(move || { let cors = Cors::default() .allowed_origin("tauri://localhost") @@ -119,6 +154,8 @@ pub async fn start_server(state: Arc, port: u16, token: String) - .wrap(cors) .app_data(state_data.clone()) .app_data(token_data.clone()) + .app_data(runtime_data.clone()) + .app_data(cache_dir_data.clone()) .service(stream_media) }) .bind(("127.0.0.1", port))? diff --git a/app/src-tauri/src/vault/cache.rs b/app/src-tauri/src/vault/cache.rs new file mode 100644 index 0000000..b08af0c --- /dev/null +++ b/app/src-tauri/src/vault/cache.rs @@ -0,0 +1,134 @@ +use std::path::{Path, PathBuf}; + +use grammers_client::Client; +use zeroize::Zeroize; + +use super::crypto::{unwrap_key, KEY_LEN}; +use super::format::{decrypt_file_to_path, file_key_aad}; +use super::manifest::FileRecord; +use super::state::{random_positive_id, VaultRuntime}; +use super::storage; + +#[derive(Debug, Clone)] +pub struct CachedVaultFile { + pub path: PathBuf, + pub name: String, +} + +fn safe_extension(name: &str) -> Option { + Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + ext.chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .take(16) + .collect::() + }) + .filter(|ext| !ext.is_empty()) +} + +fn cached_plaintext_path(cache_dir: &Path, namespace: &str, record: &FileRecord) -> PathBuf { + let mut filename = format!( + "{}-{}-{}", + record.id, record.blob_message_id, record.ciphertext_size + ); + if let Some(ext) = safe_extension(&record.name) { + filename.push('.'); + filename.push_str(&ext); + } + cache_dir.join(namespace).join(filename) +} + +pub async fn decrypt_file_to_cache( + client: &Client, + runtime: &VaultRuntime, + cache_dir: &Path, + message_id: i64, + folder_id: Option, + namespace: &str, +) -> Result { + let (record, bucket_id, vault_id, mut master_key): (FileRecord, i64, String, [u8; KEY_LEN]) = { + let guard = runtime.inner.lock().await; + let vault = guard + .as_ref() + .ok_or_else(|| "Vault is locked. Unlock it before opening files.".to_string())?; + let record = vault + .manifest + .files + .iter() + .find(|file| file.id == message_id && file.folder_id == folder_id) + .ok_or_else(|| "File not found".to_string())? + .clone(); + + ( + record, + vault.config.bucket_id, + vault.config.vault_id.clone(), + vault.master_key, + ) + }; + + let output_path = cached_plaintext_path(cache_dir, namespace, &record); + if output_path.exists() { + if std::fs::metadata(&output_path) + .map(|metadata| metadata.len() == record.size) + .unwrap_or(false) + { + master_key.zeroize(); + return Ok(CachedVaultFile { + path: output_path, + name: record.name, + }); + } + let _ = std::fs::remove_file(&output_path); + } + + let output_parent = output_path + .parent() + .ok_or_else(|| "Invalid cache output path".to_string())?; + std::fs::create_dir_all(output_parent) + .map_err(|e| format!("Failed to create vault cache: {}", e))?; + + let temp_dir = cache_dir.join("tmp"); + std::fs::create_dir_all(&temp_dir) + .map_err(|e| format!("Failed to create vault temp cache: {}", e))?; + let encrypted_temp = tempfile::Builder::new() + .prefix("tdv1-cache-") + .suffix(".blob") + .tempfile_in(&temp_dir) + .map_err(|e| format!("Failed to create ciphertext cache temp file: {}", e))?; + let encrypted_path = encrypted_temp.path().to_path_buf(); + + storage::download_object(client, bucket_id, record.blob_message_id, &encrypted_path).await?; + + let tmp_plaintext_path = + output_parent.join(format!(".{}.tmp-{}", record.id, random_positive_id())); + let mut file_key = unwrap_key( + &master_key, + &record.wrapped_file_key, + &file_key_aad(&vault_id, record.id), + )?; + master_key.zeroize(); + + let decrypt_result = decrypt_file_to_path( + &encrypted_path, + &tmp_plaintext_path, + &vault_id, + record.id, + &file_key, + ); + file_key.zeroize(); + if let Err(error) = decrypt_result { + let _ = std::fs::remove_file(&tmp_plaintext_path); + return Err(error); + } + + std::fs::rename(&tmp_plaintext_path, &output_path) + .map_err(|e| format!("Failed to store decrypted cache file: {}", e))?; + + Ok(CachedVaultFile { + path: output_path, + name: record.name, + }) +} diff --git a/app/src-tauri/src/vault/crypto.rs b/app/src-tauri/src/vault/crypto.rs new file mode 100644 index 0000000..b94412b --- /dev/null +++ b/app/src-tauri/src/vault/crypto.rs @@ -0,0 +1,172 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::{engine::general_purpose, Engine as _}; +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +pub const KEY_LEN: usize = 32; +pub const NONCE_LEN: usize = 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WrappedSecret { + pub nonce: String, + pub ciphertext: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KdfParams { + pub algorithm: String, + pub memory_kib: u32, + pub iterations: u32, + pub parallelism: u32, +} + +impl Default for KdfParams { + fn default() -> Self { + Self { + algorithm: "argon2id".to_string(), + memory_kib: 64 * 1024, + iterations: 3, + parallelism: 1, + } + } +} + +pub fn random_bytes() -> [u8; N] { + let mut out = [0u8; N]; + rand::thread_rng().fill_bytes(&mut out); + out +} + +pub fn random_key() -> [u8; KEY_LEN] { + random_bytes::() +} + +pub fn b64_encode(bytes: &[u8]) -> String { + general_purpose::STANDARD.encode(bytes) +} + +pub fn b64_decode(value: &str) -> Result, String> { + general_purpose::STANDARD + .decode(value) + .map_err(|e| format!("Invalid base64: {}", e)) +} + +pub fn derive_unlock_key( + password: &str, + salt: &[u8], + params: &KdfParams, +) -> Result<[u8; KEY_LEN], String> { + if params.algorithm != "argon2id" { + return Err(format!("Unsupported KDF: {}", params.algorithm)); + } + + let params = Params::new( + params.memory_kib, + params.iterations, + params.parallelism, + Some(KEY_LEN), + ) + .map_err(|e| format!("Invalid KDF params: {}", e))?; + + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; KEY_LEN]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| format!("Password derivation failed: {}", e))?; + Ok(key) +} + +pub fn encrypt_aead( + key: &[u8; KEY_LEN], + plaintext: &[u8], + aad: &[u8], +) -> Result { + let nonce = random_bytes::(); + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|_| "Invalid encryption key length".to_string())?; + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| "Encryption failed".to_string())?; + + Ok(WrappedSecret { + nonce: b64_encode(&nonce), + ciphertext: b64_encode(&ciphertext), + }) +} + +pub fn decrypt_aead( + key: &[u8; KEY_LEN], + wrapped: &WrappedSecret, + aad: &[u8], +) -> Result, String> { + let nonce = b64_decode(&wrapped.nonce)?; + if nonce.len() != NONCE_LEN { + return Err("Invalid nonce length".to_string()); + } + let ciphertext = b64_decode(&wrapped.ciphertext)?; + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|_| "Invalid encryption key length".to_string())?; + cipher + .decrypt( + XNonce::from_slice(&nonce), + Payload { + msg: ciphertext.as_ref(), + aad, + }, + ) + .map_err(|_| "Authentication failed".to_string()) +} + +pub fn wrap_key( + wrapping_key: &[u8; KEY_LEN], + key_to_wrap: &[u8; KEY_LEN], + aad: &[u8], +) -> Result { + encrypt_aead(wrapping_key, key_to_wrap, aad) +} + +pub fn unwrap_key( + wrapping_key: &[u8; KEY_LEN], + wrapped: &WrappedSecret, + aad: &[u8], +) -> Result<[u8; KEY_LEN], String> { + let mut plaintext = decrypt_aead(wrapping_key, wrapped, aad)?; + if plaintext.len() != KEY_LEN { + plaintext.zeroize(); + return Err("Invalid wrapped key length".to_string()); + } + let mut out = [0u8; KEY_LEN]; + out.copy_from_slice(&plaintext); + plaintext.zeroize(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aead_rejects_wrong_associated_data() { + let key = random_key(); + let wrapped = encrypt_aead(&key, b"secret", b"aad-1").unwrap(); + assert!(decrypt_aead(&key, &wrapped, b"aad-2").is_err()); + } + + #[test] + fn key_wrap_round_trip() { + let master = random_key(); + let file_key = random_key(); + let wrapped = wrap_key(&master, &file_key, b"file-key").unwrap(); + let unwrapped = unwrap_key(&master, &wrapped, b"file-key").unwrap(); + assert_eq!(file_key, unwrapped); + } +} diff --git a/app/src-tauri/src/vault/format.rs b/app/src-tauri/src/vault/format.rs new file mode 100644 index 0000000..c0f8bb9 --- /dev/null +++ b/app/src-tauri/src/vault/format.rs @@ -0,0 +1,307 @@ +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::Path; + +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +use super::crypto::{ + decrypt_aead, encrypt_aead, random_bytes, KdfParams, WrappedSecret, KEY_LEN, NONCE_LEN, +}; +use super::manifest::VaultManifest; + +const BLOB_MAGIC: &[u8; 8] = b"TDV1BLB!"; +const BLOB_VERSION: u32 = 1; +pub const DEFAULT_CHUNK_SIZE: usize = 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultConfig { + pub version: u32, + pub vault_id: String, + pub bucket_id: i64, + pub kdf: KdfParams, + pub salt: String, + pub wrapped_master_key: WrappedSecret, + pub latest_manifest_message_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedManifestFile { + pub magic: String, + pub version: u32, + pub vault_id: String, + pub generation: u64, + pub encrypted_manifest: WrappedSecret, +} + +pub struct EncryptedFileStats { + pub plaintext_size: u64, + pub ciphertext_size: u64, + pub chunk_size: u32, + pub chunk_count: u64, +} + +pub fn header_aad(vault_id: &str) -> Vec { + format!("td:v1:header:{}", vault_id).into_bytes() +} + +pub fn manifest_aad(vault_id: &str, generation: u64) -> Vec { + format!("td:v1:manifest:{}:{}", vault_id, generation).into_bytes() +} + +pub fn file_key_aad(vault_id: &str, file_id: i64) -> Vec { + format!("td:v1:file-key:{}:{}", vault_id, file_id).into_bytes() +} + +fn chunk_aad(vault_id: &str, file_id: i64, chunk_index: u64, plain_len: u32) -> Vec { + let mut aad = Vec::with_capacity(vault_id.len() + 64); + aad.extend_from_slice(b"td:v1:chunk:"); + aad.extend_from_slice(vault_id.as_bytes()); + aad.extend_from_slice(&file_id.to_le_bytes()); + aad.extend_from_slice(&chunk_index.to_le_bytes()); + aad.extend_from_slice(&plain_len.to_le_bytes()); + aad +} + +pub fn encrypt_manifest( + master_key: &[u8; KEY_LEN], + manifest: &VaultManifest, +) -> Result { + let plaintext = + serde_json::to_vec(manifest).map_err(|e| format!("Failed to serialize manifest: {}", e))?; + let aad = manifest_aad(&manifest.vault_id, manifest.generation); + let encrypted_manifest = encrypt_aead(master_key, &plaintext, &aad)?; + Ok(EncryptedManifestFile { + magic: "TDV1MANIFEST".to_string(), + version: 1, + vault_id: manifest.vault_id.clone(), + generation: manifest.generation, + encrypted_manifest, + }) +} + +pub fn decrypt_manifest( + master_key: &[u8; KEY_LEN], + encrypted: &EncryptedManifestFile, +) -> Result { + if encrypted.magic != "TDV1MANIFEST" || encrypted.version != 1 { + return Err("Unsupported manifest format".to_string()); + } + let aad = manifest_aad(&encrypted.vault_id, encrypted.generation); + let plaintext = decrypt_aead(master_key, &encrypted.encrypted_manifest, &aad)?; + serde_json::from_slice(&plaintext).map_err(|e| format!("Failed to parse manifest: {}", e)) +} + +pub fn read_encrypted_manifest(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("Failed to read manifest: {}", e))?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse encrypted manifest: {}", e)) +} + +pub fn write_encrypted_manifest( + path: &Path, + encrypted: &EncryptedManifestFile, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create vault dir: {}", e))?; + } + let bytes = serde_json::to_vec_pretty(encrypted) + .map_err(|e| format!("Failed to serialize encrypted manifest: {}", e))?; + std::fs::write(path, bytes).map_err(|e| format!("Failed to write manifest: {}", e)) +} + +pub fn encrypt_file_to_path( + input_path: &Path, + output_path: &Path, + vault_id: &str, + file_id: i64, + file_key: &[u8; KEY_LEN], +) -> Result { + let input = File::open(input_path).map_err(|e| format!("Failed to open source file: {}", e))?; + let mut reader = BufReader::new(input); + let output = File::create(output_path) + .map_err(|e| format!("Failed to create encrypted temp file: {}", e))?; + let mut writer = BufWriter::new(output); + let cipher = XChaCha20Poly1305::new_from_slice(file_key) + .map_err(|_| "Invalid file key length".to_string())?; + + writer.write_all(BLOB_MAGIC).map_err(|e| e.to_string())?; + writer + .write_all(&BLOB_VERSION.to_le_bytes()) + .map_err(|e| e.to_string())?; + writer + .write_all(&file_id.to_le_bytes()) + .map_err(|e| e.to_string())?; + writer + .write_all(&(DEFAULT_CHUNK_SIZE as u32).to_le_bytes()) + .map_err(|e| e.to_string())?; + + let mut plaintext_size = 0u64; + let mut chunk_count = 0u64; + let mut buffer = vec![0u8; DEFAULT_CHUNK_SIZE]; + + loop { + let read = reader + .read(&mut buffer) + .map_err(|e| format!("Failed to read source file: {}", e))?; + if read == 0 { + break; + } + + let plain_len = read as u32; + let nonce = random_bytes::(); + let aad = chunk_aad(vault_id, file_id, chunk_count, plain_len); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: &buffer[..read], + aad: &aad, + }, + ) + .map_err(|_| "File chunk encryption failed".to_string())?; + + writer.write_all(&nonce).map_err(|e| e.to_string())?; + writer + .write_all(&plain_len.to_le_bytes()) + .map_err(|e| e.to_string())?; + writer + .write_all(&(ciphertext.len() as u32).to_le_bytes()) + .map_err(|e| e.to_string())?; + writer.write_all(&ciphertext).map_err(|e| e.to_string())?; + + plaintext_size += read as u64; + chunk_count += 1; + } + + buffer.zeroize(); + writer.flush().map_err(|e| e.to_string())?; + let ciphertext_size = std::fs::metadata(output_path) + .map_err(|e| format!("Failed to stat encrypted temp file: {}", e))? + .len(); + + Ok(EncryptedFileStats { + plaintext_size, + ciphertext_size, + chunk_size: DEFAULT_CHUNK_SIZE as u32, + chunk_count, + }) +} + +pub fn decrypt_file_to_path( + input_path: &Path, + output_path: &Path, + vault_id: &str, + expected_file_id: i64, + file_key: &[u8; KEY_LEN], +) -> Result<(), String> { + let input = + File::open(input_path).map_err(|e| format!("Failed to open encrypted file: {}", e))?; + let mut reader = BufReader::new(input); + let output = + File::create(output_path).map_err(|e| format!("Failed to create output file: {}", e))?; + let mut writer = BufWriter::new(output); + + let mut magic = [0u8; 8]; + reader + .read_exact(&mut magic) + .map_err(|e| format!("Invalid encrypted file header: {}", e))?; + if &magic != BLOB_MAGIC { + return Err("Unsupported encrypted file magic".to_string()); + } + + let version = read_u32(&mut reader)?; + if version != BLOB_VERSION { + return Err(format!("Unsupported encrypted file version: {}", version)); + } + + let file_id = read_i64(&mut reader)?; + if file_id != expected_file_id { + return Err("Encrypted file identity mismatch".to_string()); + } + + let _chunk_size = read_u32(&mut reader)?; + let cipher = XChaCha20Poly1305::new_from_slice(file_key) + .map_err(|_| "Invalid file key length".to_string())?; + + let mut chunk_index = 0u64; + loop { + let mut nonce = [0u8; NONCE_LEN]; + match reader.read_exact(&mut nonce) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(format!("Failed to read encrypted chunk nonce: {}", e)), + } + + let plain_len = read_u32(&mut reader)?; + let cipher_len = read_u32(&mut reader)? as usize; + let mut ciphertext = vec![0u8; cipher_len]; + reader + .read_exact(&mut ciphertext) + .map_err(|e| format!("Failed to read encrypted chunk: {}", e))?; + + let aad = chunk_aad(vault_id, file_id, chunk_index, plain_len); + let mut plaintext = cipher + .decrypt( + XNonce::from_slice(&nonce), + Payload { + msg: ciphertext.as_ref(), + aad: &aad, + }, + ) + .map_err(|_| "Encrypted chunk authentication failed".to_string())?; + + if plaintext.len() != plain_len as usize { + plaintext.zeroize(); + return Err("Encrypted chunk length mismatch".to_string()); + } + writer.write_all(&plaintext).map_err(|e| e.to_string())?; + plaintext.zeroize(); + ciphertext.zeroize(); + chunk_index += 1; + } + + writer.flush().map_err(|e| e.to_string()) +} + +fn read_u32(reader: &mut BufReader) -> Result { + let mut bytes = [0u8; 4]; + reader.read_exact(&mut bytes).map_err(|e| e.to_string())?; + Ok(u32::from_le_bytes(bytes)) +} + +fn read_i64(reader: &mut BufReader) -> Result { + let mut bytes = [0u8; 8]; + reader.read_exact(&mut bytes).map_err(|e| e.to_string())?; + Ok(i64::from_le_bytes(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_round_trip_and_tamper_rejection() { + let tmp = tempfile::tempdir().unwrap(); + let input = tmp.path().join("plain.txt"); + let encrypted = tmp.path().join("cipher.bin"); + let output = tmp.path().join("out.txt"); + std::fs::write(&input, b"hello encrypted telegram drive").unwrap(); + let key = super::super::crypto::random_key(); + + encrypt_file_to_path(&input, &encrypted, "vault-test", 42, &key).unwrap(); + decrypt_file_to_path(&encrypted, &output, "vault-test", 42, &key).unwrap(); + assert_eq!( + std::fs::read(&input).unwrap(), + std::fs::read(&output).unwrap() + ); + + let mut bytes = std::fs::read(&encrypted).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + std::fs::write(&encrypted, bytes).unwrap(); + assert!(decrypt_file_to_path(&encrypted, &output, "vault-test", 42, &key).is_err()); + } +} diff --git a/app/src-tauri/src/vault/manifest.rs b/app/src-tauri/src/vault/manifest.rs new file mode 100644 index 0000000..3761cd7 --- /dev/null +++ b/app/src-tauri/src/vault/manifest.rs @@ -0,0 +1,67 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use super::crypto::WrappedSecret; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultManifest { + pub version: u32, + pub vault_id: String, + pub generation: u64, + pub bucket_id: i64, + pub folders: Vec, + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FolderRecord { + pub id: i64, + pub parent_id: Option, + pub name: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileRecord { + pub id: i64, + pub folder_id: Option, + pub name: String, + pub size: u64, + pub mime_type: Option, + pub file_ext: Option, + pub created_at: String, + pub modified_at: String, + pub blob_message_id: i32, + pub ciphertext_size: u64, + pub chunk_size: u32, + pub chunk_count: u64, + pub wrapped_file_key: WrappedSecret, +} + +impl VaultManifest { + pub fn new(vault_id: String, bucket_id: i64) -> Self { + Self { + version: 1, + vault_id, + generation: 0, + bucket_id, + folders: Vec::new(), + files: Vec::new(), + } + } + + pub fn touch_generation(&mut self) { + self.generation = self.generation.saturating_add(1); + } + + pub fn now() -> String { + Utc::now().to_rfc3339() + } + + pub fn folder_exists(&self, folder_id: Option) -> bool { + match folder_id { + None => true, + Some(id) => self.folders.iter().any(|folder| folder.id == id), + } + } +} diff --git a/app/src-tauri/src/vault/mod.rs b/app/src-tauri/src/vault/mod.rs new file mode 100644 index 0000000..018405c --- /dev/null +++ b/app/src-tauri/src/vault/mod.rs @@ -0,0 +1,8 @@ +pub mod cache; +pub mod crypto; +pub mod format; +pub mod manifest; +pub mod state; +pub mod storage; + +pub use state::VaultRuntime; diff --git a/app/src-tauri/src/vault/state.rs b/app/src-tauri/src/vault/state.rs new file mode 100644 index 0000000..c7122c5 --- /dev/null +++ b/app/src-tauri/src/vault/state.rs @@ -0,0 +1,177 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use rand::Rng; +use serde::Serialize; +use tauri::Manager; +use tokio::sync::Mutex; +use zeroize::Zeroize; + +use super::crypto::{ + b64_decode, derive_unlock_key, random_key, unwrap_key, wrap_key, KdfParams, KEY_LEN, +}; +use super::format::{ + decrypt_manifest, encrypt_manifest, header_aad, read_encrypted_manifest, + write_encrypted_manifest, VaultConfig, +}; +use super::manifest::VaultManifest; + +#[derive(Clone)] +pub struct VaultRuntime { + pub inner: Arc>>, +} + +impl Default for VaultRuntime { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + } + } +} + +#[derive(Debug)] +pub struct UnlockedVault { + pub config: VaultConfig, + pub master_key: [u8; KEY_LEN], + pub manifest: VaultManifest, +} + +impl Drop for UnlockedVault { + fn drop(&mut self) { + self.master_key.zeroize(); + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultStatus { + pub configured: bool, + pub unlocked: bool, + pub vault_id: Option, + pub generation: Option, +} + +pub fn vault_dir(app_handle: &tauri::AppHandle) -> Result { + Ok(app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data directory: {}", e))? + .join("vault")) +} + +pub fn vault_cache_dir(app_handle: &tauri::AppHandle) -> Result { + let path = app_handle + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to resolve app cache directory: {}", e))? + .join("vault"); + std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create vault cache: {}", e))?; + Ok(path) +} + +pub fn config_path(app_handle: &tauri::AppHandle) -> Result { + Ok(vault_dir(app_handle)?.join("vault.json")) +} + +pub fn manifest_path(app_handle: &tauri::AppHandle) -> Result { + Ok(vault_dir(app_handle)?.join("manifest.tdv")) +} + +pub fn load_config(app_handle: &tauri::AppHandle) -> Result { + let path = config_path(app_handle)?; + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read vault config: {}", e))?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse vault config: {}", e)) +} + +pub fn save_config(app_handle: &tauri::AppHandle, config: &VaultConfig) -> Result<(), String> { + let path = config_path(app_handle)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create vault dir: {}", e))?; + } + let bytes = serde_json::to_vec_pretty(config) + .map_err(|e| format!("Failed to serialize vault config: {}", e))?; + std::fs::write(path, bytes).map_err(|e| format!("Failed to write vault config: {}", e)) +} + +pub fn config_exists(app_handle: &tauri::AppHandle) -> bool { + config_path(app_handle) + .map(|path| path.exists()) + .unwrap_or(false) +} + +pub fn make_config( + password: &str, + vault_id: String, + bucket_id: i64, +) -> Result<(VaultConfig, [u8; KEY_LEN]), String> { + let kdf = KdfParams::default(); + let salt = super::crypto::random_bytes::<16>(); + let mut unlock_key = derive_unlock_key(password, &salt, &kdf)?; + let master_key = random_key(); + let wrapped_master_key = wrap_key(&unlock_key, &master_key, &header_aad(&vault_id))?; + unlock_key.zeroize(); + Ok(( + VaultConfig { + version: 1, + vault_id, + bucket_id, + kdf, + salt: super::crypto::b64_encode(&salt), + wrapped_master_key, + latest_manifest_message_id: None, + }, + master_key, + )) +} + +pub fn unlock_from_disk( + app_handle: &tauri::AppHandle, + password: &str, +) -> Result { + let config = load_config(app_handle)?; + if config.version != 1 { + return Err(format!( + "Unsupported vault config version: {}", + config.version + )); + } + let salt = b64_decode(&config.salt)?; + let mut unlock_key = derive_unlock_key(password, &salt, &config.kdf)?; + let master_key = unwrap_key( + &unlock_key, + &config.wrapped_master_key, + &header_aad(&config.vault_id), + )?; + unlock_key.zeroize(); + + let encrypted_manifest = read_encrypted_manifest(&manifest_path(app_handle)?)?; + let manifest = decrypt_manifest(&master_key, &encrypted_manifest)?; + if manifest.vault_id != config.vault_id || manifest.bucket_id != config.bucket_id { + return Err("Vault manifest does not match local vault config".to_string()); + } + + Ok(UnlockedVault { + config, + master_key, + manifest, + }) +} + +pub fn save_local_manifest( + app_handle: &tauri::AppHandle, + vault: &UnlockedVault, +) -> Result<(), String> { + let encrypted = encrypt_manifest(&vault.master_key, &vault.manifest)?; + write_encrypted_manifest(&manifest_path(app_handle)?, &encrypted) +} + +pub fn random_positive_id() -> i64 { + const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + loop { + let raw = rand::thread_rng().gen_range(1..=JS_MAX_SAFE_INTEGER); + if raw > 0 { + return raw; + } + } +} diff --git a/app/src-tauri/src/vault/storage.rs b/app/src-tauri/src/vault/storage.rs new file mode 100644 index 0000000..e7c61b5 --- /dev/null +++ b/app/src-tauri/src/vault/storage.rs @@ -0,0 +1,129 @@ +use std::io::Write; +use std::path::Path; + +use grammers_client::types::{Media, Peer}; +use grammers_client::{Client, InputMessage}; +use grammers_tl_types as tl; + +use crate::commands::utils::{map_error, resolve_peer}; + +pub async fn create_ciphertext_bucket(client: &Client, vault_id: &str) -> Result { + let result = client + .invoke(&tl::functions::channels::CreateChannel { + broadcast: true, + megagroup: false, + title: "TelegramVault".to_string(), + about: format!( + "Telegram Drive encrypted vault bucket\n[telegram-drive-vault-v1:{}]", + vault_id + ), + geo_point: None, + address: None, + for_import: false, + forum: false, + ttl_period: None, + }) + .await + .map_err(map_error)?; + + let (chat_id, access_hash) = match result { + tl::enums::Updates::Updates(u) => { + let chat = u + .chats + .first() + .ok_or("No chat in channel creation response")?; + match chat { + tl::enums::Chat::Channel(c) => (c.id, c.access_hash.unwrap_or(0)), + _ => return Err("Created bucket is not a Telegram channel".to_string()), + } + } + _ => return Err("Unexpected channel creation response".to_string()), + }; + + let _ = client + .invoke(&tl::functions::messages::SetHistoryTtl { + peer: tl::enums::InputPeer::Channel(tl::types::InputPeerChannel { + channel_id: chat_id, + access_hash, + }), + period: 0, + }) + .await; + + Ok(chat_id) +} + +pub async fn upload_object( + client: &Client, + bucket_id: i64, + path: &Path, + caption: String, +) -> Result { + let peer = resolve_peer(client, Some(bucket_id)).await?; + let path_str = path.to_string_lossy().to_string(); + let uploaded = client.upload_file(&path_str).await.map_err(map_error)?; + let message = client + .send_message(&peer, InputMessage::new().text(caption).file(uploaded)) + .await + .map_err(map_error)?; + Ok(message.id()) +} + +pub async fn download_object( + client: &Client, + bucket_id: i64, + message_id: i32, + output_path: &Path, +) -> Result { + let peer = resolve_peer(client, Some(bucket_id)).await?; + let messages = client + .get_messages_by_id(&peer, &[message_id]) + .await + .map_err(|e| e.to_string())?; + let msg = messages + .into_iter() + .flatten() + .next() + .ok_or_else(|| "Ciphertext object not found".to_string())?; + let media = msg + .media() + .ok_or_else(|| "Ciphertext object has no media".to_string())?; + + let mut file = std::fs::File::create(output_path) + .map_err(|e| format!("Failed to create ciphertext temp file: {}", e))?; + let mut downloaded = 0u64; + let mut download_iter = client.iter_download(&media); + while let Some(chunk) = download_iter.next().await.transpose() { + let bytes = chunk.map_err(|e| format!("Download chunk error: {}", e))?; + file.write_all(&bytes).map_err(|e| e.to_string())?; + downloaded += bytes.len() as u64; + } + Ok(downloaded) +} + +pub async fn delete_object(client: &Client, bucket_id: i64, message_id: i32) -> Result<(), String> { + let peer = resolve_peer(client, Some(bucket_id)).await?; + client + .delete_messages(&peer, &[message_id]) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[allow(dead_code)] +pub fn media_size(media: &Media) -> u64 { + match media { + Media::Document(d) => d.size() as u64, + Media::Photo(_) => 1024 * 1024, + _ => 0, + } +} + +#[allow(dead_code)] +pub fn peer_id(peer: &Peer) -> Option { + match peer { + Peer::Channel(channel) => Some(channel.raw.id), + Peer::User(user) => Some(user.raw.id()), + _ => None, + } +} diff --git a/app/src/App.css b/app/src/App.css index 2fe4fbe..0e96971 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -92,6 +92,14 @@ body, color: #1e40af; } +:root.light .auth-glass .storage-mode-title { + color: #111827; +} + +:root.light .auth-glass .storage-mode-copy { + color: #374151; +} + :root.light .auth-glass .placeholder-gray-600::placeholder { color: #9ca3af; } @@ -245,4 +253,4 @@ body, :root.light input:focus, :root.light textarea:focus { border-color: var(--color-telegram-primary); -} \ No newline at end of file +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 5701d61..84249e6 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -2,9 +2,12 @@ import { useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AuthWizard } from "./components/AuthWizard"; import { Dashboard } from "./components/Dashboard"; +import { DriveModeSelector } from "./components/DriveModeSelector"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { UpdateBanner } from "./components/UpdateBanner"; +import { VaultWizard } from "./components/VaultWizard"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; +import { DriveMode } from "./types"; import "./App.css"; import { Toaster } from "sonner"; @@ -16,9 +19,17 @@ const queryClient = new QueryClient(); function AppContent() { const [isAuthenticated, setIsAuthenticated] = useState(false); + const [driveMode, setDriveMode] = useState(null); + const [isVaultUnlocked, setIsVaultUnlocked] = useState(false); const { theme } = useTheme(); const { available, version, downloading, progress, downloadAndInstall, dismissUpdate } = useUpdateCheck(); + const resetSession = () => { + setIsVaultUnlocked(false); + setDriveMode(null); + setIsAuthenticated(false); + }; + return (
- {isAuthenticated ? ( - setIsAuthenticated(false)} /> + {isAuthenticated && driveMode === 'plain' ? ( + + ) : isAuthenticated && driveMode === 'vault' && isVaultUnlocked ? ( + + ) : isAuthenticated && driveMode === 'vault' ? ( + setIsVaultUnlocked(true)} + onBack={() => { + setIsVaultUnlocked(false); + setDriveMode(null); + }} + /> + ) : isAuthenticated ? ( + ) : ( setIsAuthenticated(true)} /> )} diff --git a/app/src/components/Dashboard.tsx b/app/src/components/Dashboard.tsx index f1982b7..a87aea2 100644 --- a/app/src/components/Dashboard.tsx +++ b/app/src/components/Dashboard.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api/core'; import { toast } from 'sonner'; -import { TelegramFile, BandwidthStats } from '../types'; +import { DriveMode, TelegramFile, BandwidthStats } from '../types'; import { formatBytes, isMediaFile, isPdfFile } from '../utils'; // Components @@ -27,14 +27,14 @@ import { useFileUpload } from '../hooks/useFileUpload'; import { useFileDownload } from '../hooks/useFileDownload'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; -export function Dashboard({ onLogout }: { onLogout: () => void }) { +export function Dashboard({ driveMode, onLogout }: { driveMode: DriveMode; onLogout: () => void }) { const queryClient = useQueryClient(); const { store, folders, activeFolderId, setActiveFolderId, isSyncing, isConnected, handleLogout, handleSyncFolders, handleCreateFolder, handleFolderDelete - } = useTelegramConnection(onLogout); + } = useTelegramConnection(driveMode, onLogout); const [previewFile, setPreviewFile] = useState(null); @@ -72,8 +72,8 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { const { data: allFiles = [], isLoading, error } = useQuery({ - queryKey: ['files', activeFolderId], - queryFn: () => invoke('cmd_get_files', { folderId: activeFolderId }).then(res => res.map(f => ({ + queryKey: ['files', driveMode, activeFolderId], + queryFn: () => invoke(driveMode === 'vault' ? 'cmd_vault_get_files' : 'cmd_get_files', { folderId: activeFolderId }).then(res => res.map(f => ({ ...f, sizeStr: formatBytes(f.size), type: f.icon_type || (f.name.endsWith('/') ? 'folder' : 'file') @@ -97,10 +97,10 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { handleDelete, handleBulkDelete, handleBulkDownload, handleBulkMove, handleDownloadFolder, handleGlobalSearch - } = useFileOperations(activeFolderId, selectedIds, setSelectedIds, displayedFiles); + } = useFileOperations(driveMode, activeFolderId, selectedIds, setSelectedIds, displayedFiles); - const { uploadQueue, setUploadQueue, handleManualUpload, cancelAll: cancelUploads, isDragging } = useFileUpload(activeFolderId, store); - const { downloadQueue, queueDownload, clearFinished: clearDownloads, cancelAll: cancelDownloads } = useFileDownload(store); + const { uploadQueue, setUploadQueue, handleManualUpload, cancelAll: cancelUploads, isDragging } = useFileUpload(activeFolderId, store, driveMode); + const { downloadQueue, queueDownload, clearFinished: clearDownloads, cancelAll: cancelDownloads } = useFileDownload(store, driveMode); const handleSelectAll = useCallback(() => { @@ -194,6 +194,11 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { } const handlePreview = (file: TelegramFile, orderedFiles?: TelegramFile[]) => { + if (file.type === 'folder') { + setActiveFolderId(file.id); + return; + } + const contextFiles = (orderedFiles || displayedFiles).filter((f) => f.type !== 'folder'); const contextIndex = contextFiles.findIndex((f) => f.id === file.id); @@ -297,13 +302,13 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { try { const idsToMove = selectedIds.includes(fileId) ? selectedIds : [fileId]; - await invoke('cmd_move_files', { + await invoke(driveMode === 'vault' ? 'cmd_vault_move_files' : 'cmd_move_files', { messageIds: idsToMove, sourceFolderId: activeFolderId, targetFolderId: targetFolderId }); - queryClient.invalidateQueries({ queryKey: ['files', activeFolderId] }); + queryClient.invalidateQueries({ queryKey: ['files', driveMode, activeFolderId] }); if (selectedIds.includes(fileId)) setSelectedIds([]); @@ -317,7 +322,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { } const currentFolderName = activeFolderId === null - ? "Saved Messages" + ? (driveMode === 'vault' ? "TelegramVault" : "Saved Messages") : folders.find(f => f.id === activeFolderId)?.name || "Folder"; @@ -356,6 +361,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { onClose={() => setShowMoveModal(false)} onSelect={handleBulkMove} activeFolderId={activeFolderId} + rootLabel={driveMode === 'vault' ? 'TelegramVault' : 'Saved Messages'} key="move-modal" /> )} @@ -368,6 +374,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { currentIndex={previewContextIndex} totalItems={previewContextFiles.length} activeFolderId={activeFolderId} + driveMode={driveMode} key="media-player" /> )} @@ -380,6 +387,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { currentIndex={previewContextIndex} totalItems={previewContextFiles.length} activeFolderId={activeFolderId} + driveMode={driveMode} key="pdf-viewer" /> )} @@ -398,6 +406,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { onSync={handleSyncFolders} onLogout={handleLogout} bandwidth={bandwidth || null} + rootLabel={driveMode === 'vault' ? 'TelegramVault' : 'Saved Messages'} />
{ if (e.target === e.currentTarget) setSelectedIds([]); }}> @@ -428,8 +437,16 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { viewMode={viewMode} selectedIds={selectedIds} activeFolderId={activeFolderId} + driveMode={driveMode} onFileClick={handleFileClick} - onDelete={handleDelete} + onDelete={(id) => { + const item = displayedFiles.find((file) => file.id === id); + if (driveMode === 'vault' && item?.type === 'folder') { + handleFolderDelete(item.id, item.name); + } else { + handleDelete(id); + } + }} onDownload={(id, name) => queueDownload(id, name, activeFolderId)} onPreview={handlePreview} onManualUpload={handleManualUpload} @@ -451,6 +468,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { totalItems={previewContextFiles.length} nextFile={previewNeighbors.nextFile} prevFile={previewNeighbors.prevFile} + driveMode={driveMode} /> )} diff --git a/app/src/components/DriveModeSelector.tsx b/app/src/components/DriveModeSelector.tsx new file mode 100644 index 0000000..fb0d76e --- /dev/null +++ b/app/src/components/DriveModeSelector.tsx @@ -0,0 +1,54 @@ +import { HardDrive, Lock, ArrowRight } from "lucide-react"; +import { DriveMode } from "../types"; + +export function DriveModeSelector({ onSelect }: { onSelect: (mode: DriveMode) => void }) { + return ( +
+
+
+
+ Logo +
+

Choose Storage Mode

+

Use the existing Telegram Drive or open an encrypted vault.

+
+ +
+ + + +
+
+
+ ); +} diff --git a/app/src/components/VaultWizard.tsx b/app/src/components/VaultWizard.tsx new file mode 100644 index 0000000..2571c3d --- /dev/null +++ b/app/src/components/VaultWizard.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ArrowRight, Lock, LogOut, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; + +interface VaultStatus { + configured: boolean; + unlocked: boolean; + vaultId?: string; + generation?: number; +} + +export function VaultWizard({ onUnlock, onBack }: { onUnlock: () => void; onBack: () => void }) { + const [status, setStatus] = useState(null); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + invoke("cmd_vault_status") + .then((result) => { + setStatus(result); + if (result.unlocked) onUnlock(); + }) + .catch((err) => setError(String(err))); + }, [onUnlock]); + + const configured = status?.configured ?? false; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!password) { + setError("Enter your vault password."); + return; + } + if (!configured && password.length < 10) { + setError("Use at least 10 characters for the vault password."); + return; + } + if (!configured && password !== confirmPassword) { + setError("Vault passwords do not match."); + return; + } + + setLoading(true); + try { + const command = configured ? "cmd_vault_unlock" : "cmd_vault_create"; + await invoke(command, { password }); + toast.success(configured ? "Vault unlocked" : "Encrypted vault created"); + onUnlock(); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ {configured ? : } +
+

+ {configured ? "Unlock Encrypted Vault" : "Create Encrypted Vault"} +

+

+ {configured + ? "Your Telegram objects stay encrypted until this device unlocks the vault." + : "Telegram will store only ciphertext blobs and encrypted vault metadata."} +

+
+ +
+
+ + setPassword(e.target.value)} + className="w-full glass-input rounded-xl px-4 py-3.5 text-white placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-all" + placeholder="Enter vault password" + /> +
+ + {!configured && ( +
+ + setConfirmPassword(e.target.value)} + className="w-full glass-input rounded-xl px-4 py-3.5 text-white placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-all" + placeholder="Repeat vault password" + /> +
+ )} + + {error && ( +
+ {error} +
+ )} + + + + +
+
+
+ ); +} diff --git a/app/src/components/dashboard/FileCard.tsx b/app/src/components/dashboard/FileCard.tsx index 472145b..ac8a8d5 100644 --- a/app/src/components/dashboard/FileCard.tsx +++ b/app/src/components/dashboard/FileCard.tsx @@ -2,7 +2,7 @@ import { motion } from 'framer-motion'; import { useState, useEffect } from 'react'; import { Folder, Eye, Trash2 } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; -import { TelegramFile } from '../../types'; +import { DriveMode, TelegramFile } from '../../types'; import { FileTypeIcon } from '../FileTypeIcon'; interface FileCardProps { @@ -17,6 +17,7 @@ interface FileCardProps { onDragStart?: (fileId: number) => void; onDragEnd?: () => void; activeFolderId?: number | null; + driveMode: DriveMode; height?: number; } @@ -26,7 +27,7 @@ function isImageFile(filename: string): boolean { return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext); } -export function FileCard({ file, onDelete, onDownload, onPreview, isSelected, onClick, onContextMenu, onDrop, onDragStart, onDragEnd, activeFolderId, height }: FileCardProps) { +export function FileCard({ file, onDelete, onDownload, onPreview, isSelected, onClick, onContextMenu, onDrop, onDragStart, onDragEnd, activeFolderId, driveMode, height }: FileCardProps) { const isFolder = file.type === 'folder'; const [isDragOver, setIsDragOver] = useState(false); const [thumbnail, setThumbnail] = useState(null); @@ -39,7 +40,7 @@ export function FileCard({ file, onDelete, onDownload, onPreview, isSelected, on let cancelled = false; setThumbnailLoading(true); - invoke('cmd_get_thumbnail', { + invoke(driveMode === 'vault' ? 'cmd_vault_get_thumbnail' : 'cmd_get_thumbnail', { messageId: file.id, folderId: activeFolderId }).then((result) => { @@ -53,7 +54,7 @@ export function FileCard({ file, onDelete, onDownload, onPreview, isSelected, on }); return () => { cancelled = true; }; - }, [file.id, file.name, activeFolderId, isFolder]); + }, [file.id, file.name, activeFolderId, driveMode, isFolder]); return (
void; onDelete: (id: number) => void; onDownload: (id: number, name: string) => void; @@ -57,7 +58,7 @@ function useGridColumns(containerRef: React.RefObject) { export function FileExplorer({ files, loading, error, viewMode, selectedIds, activeFolderId, - onFileClick, onDelete, onDownload, onPreview, onManualUpload, onSelectionClear, onDrop, onDragStart, onDragEnd + driveMode, onFileClick, onDelete, onDownload, onPreview, onManualUpload, onSelectionClear, onDrop, onDragStart, onDragEnd }: FileExplorerProps) { const [sortField, setSortField] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); @@ -252,6 +253,7 @@ export function FileExplorer({ onDragStart={onDragStart} onDragEnd={onDragEnd} activeFolderId={activeFolderId} + driveMode={driveMode} height={cardHeight} /> ); diff --git a/app/src/components/dashboard/MediaPlayer.tsx b/app/src/components/dashboard/MediaPlayer.tsx index f2c83ed..967dc18 100644 --- a/app/src/components/dashboard/MediaPlayer.tsx +++ b/app/src/components/dashboard/MediaPlayer.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { X, ChevronLeft, ChevronRight } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { TelegramFile } from '../../types'; +import { DriveMode } from '../../types'; import { isVideoFile, isAudioFile } from '../../utils'; interface MediaPlayerProps { @@ -12,9 +13,10 @@ interface MediaPlayerProps { currentIndex?: number; totalItems?: number; activeFolderId: number | null; + driveMode: DriveMode; } -export function MediaPlayer({ file, onClose, onNext, onPrev, currentIndex, totalItems, activeFolderId }: MediaPlayerProps) { +export function MediaPlayer({ file, onClose, onNext, onPrev, currentIndex, totalItems, activeFolderId, driveMode }: MediaPlayerProps) { const [streamToken, setStreamToken] = useState(null); useEffect(() => { @@ -23,7 +25,7 @@ export function MediaPlayer({ file, onClose, onNext, onPrev, currentIndex, total const folderIdParam = activeFolderId !== null ? activeFolderId.toString() : 'home'; const streamUrl = streamToken - ? `http://localhost:14200/stream/${folderIdParam}/${file.id}?token=${streamToken}` + ? `http://localhost:14200/stream/${folderIdParam}/${file.id}?token=${streamToken}${driveMode === 'vault' ? '&mode=vault' : ''}` : null; const isVideo = isVideoFile(file.name); diff --git a/app/src/components/dashboard/MoveToFolderModal.tsx b/app/src/components/dashboard/MoveToFolderModal.tsx index ba083e9..b929c1e 100644 --- a/app/src/components/dashboard/MoveToFolderModal.tsx +++ b/app/src/components/dashboard/MoveToFolderModal.tsx @@ -6,9 +6,10 @@ interface MoveToFolderModalProps { onClose: () => void; onSelect: (id: number | null) => void; activeFolderId: number | null; + rootLabel?: string; } -export function MoveToFolderModal({ folders, onClose, onSelect, activeFolderId }: MoveToFolderModalProps) { +export function MoveToFolderModal({ folders, onClose, onSelect, activeFolderId, rootLabel = "Saved Messages" }: MoveToFolderModalProps) { return (
e.stopPropagation()}> @@ -25,7 +26,7 @@ export function MoveToFolderModal({ folders, onClose, onSelect, activeFolderId }
- Saved Messages + {rootLabel} )} diff --git a/app/src/components/dashboard/PdfViewer.tsx b/app/src/components/dashboard/PdfViewer.tsx index d9bbf29..3cd45df 100644 --- a/app/src/components/dashboard/PdfViewer.tsx +++ b/app/src/components/dashboard/PdfViewer.tsx @@ -5,6 +5,7 @@ import { invoke } from '@tauri-apps/api/core'; // which isn't available in Tauri's WebKit WebView import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; import { TelegramFile } from '../../types'; +import { DriveMode } from '../../types'; // Use Vite's ?url suffix to get a properly bundled asset URL for the worker import workerUrl from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'; @@ -18,9 +19,10 @@ interface PdfViewerProps { currentIndex?: number; totalItems?: number; activeFolderId: number | null; + driveMode: DriveMode; } -export function PdfViewer({ file, onClose, onNext, onPrev, currentIndex, totalItems, activeFolderId }: PdfViewerProps) { +export function PdfViewer({ file, onClose, onNext, onPrev, currentIndex, totalItems, activeFolderId, driveMode }: PdfViewerProps) { const [streamToken, setStreamToken] = useState(null); const [pdf, setPdf] = useState(null); const [numPages, setNumPages] = useState(0); @@ -49,7 +51,7 @@ export function PdfViewer({ file, onClose, onNext, onPrev, currentIndex, totalIt setNumPages(0); const folderIdParam = activeFolderId !== null ? activeFolderId.toString() : 'home'; - const streamUrl = `http://localhost:14200/stream/${folderIdParam}/${file.id}?token=${streamToken}`; + const streamUrl = `http://localhost:14200/stream/${folderIdParam}/${file.id}?token=${streamToken}${driveMode === 'vault' ? '&mode=vault' : ''}`; const loadingTask = pdfjsLib.getDocument(streamUrl); @@ -80,7 +82,7 @@ export function PdfViewer({ file, onClose, onNext, onPrev, currentIndex, totalIt cancelled = true; loadingTask.destroy(); }; - }, [streamToken, activeFolderId, file.id]); + }, [streamToken, activeFolderId, file.id, driveMode]); // Cleanup PDF document on unmount useEffect(() => { diff --git a/app/src/components/dashboard/PreviewModal.tsx b/app/src/components/dashboard/PreviewModal.tsx index d17a994..b9e6549 100644 --- a/app/src/components/dashboard/PreviewModal.tsx +++ b/app/src/components/dashboard/PreviewModal.tsx @@ -3,6 +3,7 @@ import { X, File, ChevronLeft, ChevronRight } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core'; import { TelegramFile } from '../../types'; +import { DriveMode } from '../../types'; import { isImageFile } from '../../utils'; const PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000; @@ -62,9 +63,10 @@ interface PreviewModalProps { nextFile?: TelegramFile | null; prevFile?: TelegramFile | null; activeFolderId: number | null; + driveMode: DriveMode; } -export function PreviewModal({ file, onClose, onNext, onPrev, currentIndex, totalItems, nextFile, prevFile, activeFolderId }: PreviewModalProps) { +export function PreviewModal({ file, onClose, onNext, onPrev, currentIndex, totalItems, nextFile, prevFile, activeFolderId, driveMode }: PreviewModalProps) { const [src, setSrc] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -95,7 +97,7 @@ export function PreviewModal({ file, onClose, onNext, onPrev, currentIndex, tota setLoading(true); setError(null); try { - const path = await invoke('cmd_get_preview', { + const path = await invoke(driveMode === 'vault' ? 'cmd_vault_get_preview' : 'cmd_get_preview', { messageId: file.id, folderId: activeFolderId }); @@ -122,7 +124,7 @@ export function PreviewModal({ file, onClose, onNext, onPrev, currentIndex, tota } }; load(); - }, [file, activeFolderId, reloadNonce]); + }, [file, activeFolderId, reloadNonce, driveMode]); useEffect(() => { const candidates = [nextFile, prevFile].filter((f): f is TelegramFile => !!f && isSafeToPrefetch(f.name)); @@ -132,7 +134,7 @@ export function PreviewModal({ file, onClose, onNext, onPrev, currentIndex, tota if (getCachedPreview(key) || pendingPrefetch.has(key)) return; pendingPrefetch.add(key); - invoke('cmd_get_preview', { + invoke(driveMode === 'vault' ? 'cmd_vault_get_preview' : 'cmd_get_preview', { messageId: candidate.id, folderId: activeFolderId }).then((path) => { @@ -145,7 +147,7 @@ export function PreviewModal({ file, onClose, onNext, onPrev, currentIndex, tota pendingPrefetch.delete(key); }); }); - }, [nextFile, prevFile, activeFolderId]); + }, [nextFile, prevFile, activeFolderId, driveMode]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/app/src/components/dashboard/Sidebar.tsx b/app/src/components/dashboard/Sidebar.tsx index a7edcc4..16f7218 100644 --- a/app/src/components/dashboard/Sidebar.tsx +++ b/app/src/components/dashboard/Sidebar.tsx @@ -16,11 +16,12 @@ interface SidebarProps { onSync: () => void; onLogout: () => void; bandwidth: BandwidthStats | null; + rootLabel?: string; } export function Sidebar({ folders, activeFolderId, setActiveFolderId, onDrop, onDelete, onCreate, - isSyncing, isConnected, onSync, onLogout, bandwidth + isSyncing, isConnected, onSync, onLogout, bandwidth, rootLabel = "Saved Messages" }: SidebarProps) { const [showNewFolderInput, setShowNewFolderInput] = useState(false); const [newFolderName, setNewFolderName] = useState(""); @@ -47,7 +48,7 @@ export function Sidebar({