diff --git a/Cargo.lock b/Cargo.lock index fac9a31..450f54b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -465,6 +515,52 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -494,6 +590,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -517,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -530,7 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -661,6 +767,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "defmt" version = "1.1.1" @@ -1794,6 +1910,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1970,7 +2092,12 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", + "dbus-secret-service", "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", "zeroize", ] @@ -2145,7 +2272,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -2239,6 +2366,16 @@ dependencies = [ [[package]] name = "nut" version = "0.1.0" +dependencies = [ + "clap", + "nextcloud_client", + "open", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "url", +] [[package]] name = "objc2" @@ -2441,6 +2578,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.4.1" @@ -3100,6 +3243,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3107,7 +3263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3552,7 +3708,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3978,7 +4134,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -4349,6 +4507,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.25.0" @@ -4819,6 +4983,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4852,13 +5025,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4889,6 +5079,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4901,6 +5097,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4913,12 +5115,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4931,6 +5145,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4943,6 +5163,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4955,6 +5181,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4967,6 +5199,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -5197,6 +5435,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Tasks.md b/Tasks.md index 529abbd..4a0c989 100644 --- a/Tasks.md +++ b/Tasks.md @@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for | ID | Title | Status | Type | |---|---|---|---| -| [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature | | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature | @@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature | | [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature | +| [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature | --- @@ -232,21 +232,21 @@ Support multiple Nextcloud accounts in the backend credential system. - NUT-006 - + ### Implement CLI Frontend **ID:** NUT-008 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Implement the CLI tool using the shared backend library, including interactive browser login via Login Flow v2 and file upload commands. **Requirements:** -- [ ] Add `login` command (browser-based Login Flow v2) -- [ ] Add `upload` command -- [ ] Add `--account` flag -- [ ] Add `--stdin` support -- [ ] Add error reporting +- [x] Add `login` command (browser-based Login Flow v2) +- [x] Add `upload` command +- [x] Add `--account` flag +- [x] Add `--stdin` support +- [x] Add error reporting **Dependencies:** - NUT-003 diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f29c75d..81d0afa 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,3 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] +nextcloud_client = { path = "../nextcloud_client" } +clap = { version = "4.5", features = ["derive", "cargo"] } +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] } +open = "5.3" +url = "2.5" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/cli/src/main.rs b/cli/src/main.rs index e7a11a9..bba9309 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,349 @@ -fn main() { - println!("Hello, world!"); +use clap::{Args, Parser, Subcommand}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use nextcloud_client::{ + initiate_login_flow, poll_login_flow, ClientConfig, CredentialStore, NextcloudClient, + ProgressCallback, ProgressEvent, Result, UploadOptions, +}; + +#[derive(Parser, Debug)] +#[command( + name = "nut", + author = "Tom Hicks ", + version, + about = "Nextcloud Upload Tool — Fast, seamless file uploads & share links", + long_about = "Upload files to Nextcloud directly from your terminal, automatically generate public share links, and manage multiple accounts with secure OS keychain storage." +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Log in to a Nextcloud server using browser authorization (Login Flow v2) + Login(LoginArgs), + + /// Upload a file or stream to Nextcloud + Upload(UploadArgs), + + /// Manage configured Nextcloud accounts + #[command(subcommand)] + Account(AccountCommands), + + /// Quick alias to list all configured accounts + Accounts, +} + +#[derive(Args, Debug)] +struct LoginArgs { + /// Base URL of the Nextcloud instance (e.g. https://cloud.example.com) + server_url: String, + + /// Optional friendly label for this account (e.g. "Work", "Home") + #[arg(short, long)] + label: Option, + + /// Set this account as the default active account + #[arg(short, long, default_value_t = true)] + default: bool, +} + +#[derive(Args, Debug)] +struct UploadArgs { + /// Path to local file(s) to upload + #[arg(value_name = "FILE")] + files: Vec, + + /// Specific account identifier or label to use (defaults to active account) + #[arg(short, long)] + account: Option, + + /// Remote destination folder on Nextcloud (defaults to "Uploads") + #[arg(short = 'd', long, default_value = "Uploads")] + remote_dir: String, + + /// Automatically generate a public share link after upload + #[arg(short, long)] + share: bool, + + /// Optional password to protect the public share link + #[arg(short, long)] + password: Option, + + /// Upload content from standard input (stdin) + #[arg(long)] + stdin: bool, + + /// Remote filename to use when uploading via stdin + #[arg(long, default_value = "stdin_upload.txt")] + filename: String, +} + +#[derive(Subcommand, Debug)] +enum AccountCommands { + /// List all configured Nextcloud accounts + List, + + /// Set the active default account + Default { + /// Account ID, label, or username + account: String, + }, + + /// Remove a configured account and its credentials + Delete { + /// Account ID, label, or username + account: String, + }, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Login(args) => handle_login(args).await, + Commands::Upload(args) => handle_upload(args).await, + Commands::Account(AccountCommands::List) | Commands::Accounts => handle_account_list(), + Commands::Account(AccountCommands::Default { account }) => { + handle_account_set_default(&account) + } + Commands::Account(AccountCommands::Delete { account }) => handle_account_delete(&account), + }; + + if let Err(err) = result { + eprintln!("\x1b[1;31mError:\x1b[0m {err}"); + std::process::exit(1); + } +} + +/// Handle interactive browser login flow. +async fn handle_login(args: LoginArgs) -> Result<()> { + let server_url = ClientConfig::normalize_url(&args.server_url)?; + let http = reqwest::Client::new(); + + println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url); + let flow = initiate_login_flow(&http, &server_url).await?; + + println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:"); + println!(" \x1b[1;36m{}\x1b[0m\n", flow.login); + + // Attempt to open the default desktop browser + if open::that(&flow.login).is_err() { + println!(" (Could not automatically launch browser. Please copy and open the link above.)"); + } + + print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization..."); + io::Write::flush(&mut io::stdout())?; + + let poll_interval = Duration::from_secs(2); + let timeout = Duration::from_secs(300); // 5 minute timeout + let start_time = tokio::time::Instant::now(); + + loop { + if start_time.elapsed() >= timeout { + println!(); + return Err(nextcloud_client::NextcloudError::Other( + "Timed out waiting for authorization in browser".into(), + )); + } + + tokio::time::sleep(poll_interval).await; + + if let Some(creds) = poll_login_flow(&http, &flow.poll.endpoint, &flow.poll.token).await? { + println!("\n\x1b[1;32m==>\x1b[0m Authorization granted for user '\x1b[1m{}\x1b[0m'!", creds.login_name); + + // Save credentials to keychain & accounts list + let mut account = CredentialStore::save_account( + &creds.server, + &creds.login_name, + &creds.app_password, + args.default, + )?; + + if let Some(lbl) = args.label { + account.label = Some(lbl); + let mut accounts = CredentialStore::list_accounts()?; + if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) { + a.label = account.label.clone(); + } + CredentialStore::save_accounts(&accounts)?; + } + + println!("\x1b[1;32m==>\x1b[0m Account '\x1b[1m{}\x1b[0m' saved securely in system keychain.", account.id); + if account.is_default { + println!(" Set as default active account."); + } + return Ok(()); + } + } +} + +/// Handle file and stdin uploads. +async fn handle_upload(args: UploadArgs) -> Result<()> { + let client = match args.account.as_deref() { + Some(acc) => CredentialStore::create_client_for_account(acc)?, + None => CredentialStore::create_client_for_default()?, + }; + + // Stdin Upload + if args.stdin { + println!("\x1b[1;34m==>\x1b[0m Reading data from stdin..."); + let mut buffer = Vec::new(); + io::stdin().read_to_end(&mut buffer)?; + + let len = buffer.len() as u64; + let remote_path = format!("{}/{}", args.remote_dir.trim_matches('/'), args.filename); + println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path); + + let progress_cb = create_progress_callback(len); + let cursor = io::Cursor::new(buffer); + let bytes = client + .upload_reader(cursor, &remote_path, Some(len), Some(progress_cb)) + .await?; + + println!("\n\x1b[1;32m✓\x1b[0m Upload complete! ({} bytes)", bytes); + + if args.share { + create_and_print_share(&client, &remote_path, args.password.as_deref()).await?; + } + return Ok(()); + } + + if args.files.is_empty() { + return Err(nextcloud_client::NextcloudError::Other( + "No files specified for upload. Usage: nut upload ... or nut upload --stdin".into(), + )); + } + + for file_path in &args.files { + upload_single_file(&client, file_path, &args.remote_dir, args.share, args.password.as_deref()).await?; + } + + Ok(()) +} + +async fn upload_single_file( + client: &NextcloudClient, + local_path: &Path, + remote_dir: &str, + create_share: bool, + password: Option<&str>, +) -> Result<()> { + if !local_path.exists() { + return Err(nextcloud_client::NextcloudError::NotFound { + path: local_path.display().to_string(), + }); + } + + let file_name = local_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + + let clean_dir = remote_dir.trim_matches('/'); + let remote_path = if clean_dir.is_empty() { + file_name.to_string() + } else { + format!("{clean_dir}/{file_name}") + }; + + let metadata = tokio::fs::metadata(local_path).await?; + let file_size = metadata.len(); + + println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path); + + let progress_cb = create_progress_callback(file_size); + let options = UploadOptions { + remote_path: remote_path.clone(), + create_share, + share_password: password.map(|s| s.to_string()), + overwrite: true, + }; + + let result = client + .upload_and_share(local_path, &options, Some(progress_cb)) + .await?; + + println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", file_name, result.bytes_uploaded); + + if let Some(share_url) = result.share_url { + println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url); + } + if let Some(direct_url) = result.direct_download_url { + println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url); + } + + Ok(()) +} + +async fn create_and_print_share( + client: &NextcloudClient, + remote_path: &str, + password: Option<&str>, +) -> Result<()> { + println!("\x1b[1;34m==>\x1b[0m Generating public share link..."); + let share = client.create_public_share(remote_path, password).await?; + let direct_url = client.direct_download_url(&share.token)?; + + println!(" \x1b[1;32mShare Link:\x1b[0m {}", share.url); + println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url); + Ok(()) +} + +fn create_progress_callback(total_bytes: u64) -> ProgressCallback { + Arc::new(move |event| match event { + ProgressEvent::Progress { bytes_transferred, total_bytes: total } => { + let tot = total.unwrap_or(total_bytes); + if tot > 0 { + let percent = (bytes_transferred as f64 / tot as f64) * 100.0; + print!("\r \x1b[1;33mProgress:\x1b[0m {:>3.0}% ({}/{} bytes)", percent, bytes_transferred, tot); + } else { + print!("\r \x1b[1;33mProgress:\x1b[0m {} bytes transferred", bytes_transferred); + } + let _ = io::Write::flush(&mut io::stdout()); + } + ProgressEvent::Completed { total_bytes } => { + print!("\r \x1b[1;33mProgress:\x1b[0m 100% ({} bytes) ", total_bytes); + let _ = io::Write::flush(&mut io::stdout()); + } + _ => {} + }) +} + +/// List stored accounts. +fn handle_account_list() -> Result<()> { + let accounts = CredentialStore::list_accounts()?; + if accounts.is_empty() { + println!("No Nextcloud accounts configured yet."); + println!("Run \x1b[1;36mnut login \x1b[0m to connect an account."); + return Ok(()); + } + + println!("\x1b[1mConfigured Nextcloud Accounts:\x1b[0m\n"); + for acc in accounts { + let default_mark = if acc.is_default { "\x1b[1;32m* (active default)\x1b[0m" } else { "" }; + let label_str = acc.label.as_deref().unwrap_or("—"); + println!(" • \x1b[1;36m{}\x1b[0m {}", acc.id, default_mark); + println!(" Username: {}", acc.username); + println!(" Server: {}", acc.server_url); + println!(" Label: {}\n", label_str); + } + Ok(()) +} + +fn handle_account_set_default(account: &str) -> Result<()> { + CredentialStore::set_default_account(account)?; + println!("\x1b[1;32m✓\x1b[0m Active default account set to '\x1b[1m{}\x1b[0m'.", account); + Ok(()) +} + +fn handle_account_delete(account: &str) -> Result<()> { + CredentialStore::delete_account(account)?; + println!("\x1b[1;32m✓\x1b[0m Account '\x1b[1m{}\x1b[0m' deleted.", account); + Ok(()) } diff --git a/nextcloud_client/Cargo.toml b/nextcloud_client/Cargo.toml index e27bcfc..1825202 100644 --- a/nextcloud_client/Cargo.toml +++ b/nextcloud_client/Cargo.toml @@ -14,6 +14,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" url = { version = "2.5", features = ["serde"] } -keyring = "3" +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } dirs = "6.0" open = "5.3" diff --git a/nextcloud_client/src/config.rs b/nextcloud_client/src/config.rs index c3efb4a..d7d7d56 100644 --- a/nextcloud_client/src/config.rs +++ b/nextcloud_client/src/config.rs @@ -69,11 +69,8 @@ mod humantime_serde { } impl ClientConfig { - /// Create a new `ClientConfig` by parsing a URL string and optional credentials. - pub fn new( - server_url_str: &str, - credentials: Option, - ) -> Result { + /// Normalize a server URL string: validates http/https scheme and ensures a trailing slash. + pub fn normalize_url(server_url_str: &str) -> Result { let mut server_url = Url::parse(server_url_str)?; // Ensure scheme is http or https @@ -90,6 +87,16 @@ impl ClientConfig { server_url.set_path(&new_path); } + Ok(server_url) + } + + /// Create a new `ClientConfig` by parsing a URL string and optional credentials. + pub fn new( + server_url_str: &str, + credentials: Option, + ) -> Result { + let server_url = Self::normalize_url(server_url_str)?; + Ok(Self { server_url, credentials, @@ -127,6 +134,12 @@ mod tests { assert_eq!(config.credentials.as_ref().unwrap().username, "alice"); } + #[test] + fn test_subpath_normalization() { + let url = ClientConfig::normalize_url("https://disobedient.cloud/nextcloud").unwrap(); + assert_eq!(url.as_str(), "https://disobedient.cloud/nextcloud/"); + } + #[test] fn test_invalid_scheme() { let result = ClientConfig::new("ftp://cloud.example.com", None); diff --git a/nextcloud_client/src/credentials.rs b/nextcloud_client/src/credentials.rs index 36eaf0a..89174f1 100644 --- a/nextcloud_client/src/credentials.rs +++ b/nextcloud_client/src/credentials.rs @@ -31,7 +31,7 @@ pub struct StoredAccount { #[serde(default)] pub is_default: bool, - /// Fallback password storage (only populated if system keyring is unavailable). + /// Fallback password storage (populated if system keyring is unavailable). #[serde(skip_serializing_if = "Option::is_none")] pub fallback_password: Option, } @@ -137,7 +137,7 @@ impl CredentialStore { let keyring_key = account.keyring_key(); - // 1. Attempt retrieval from OS Keyring + // 1. Attempt retrieval from OS Keyring (using full id username@host) if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, keyring_key) { if let Ok(password) = entry.get_password() { let username = account.username.clone(); @@ -145,7 +145,7 @@ impl CredentialStore { } } - // Also try fallback by username alone if migrated + // Also try retrieval by username alone (if previously stored with just username) if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, &account.username) { if let Ok(password) = entry.get_password() { let username = account.username.clone(); @@ -158,7 +158,7 @@ impl CredentialStore { let username = account.username.clone(); return Ok(Some(( account.clone(), - AccountCredentials::new(username, pass), + AccountCredentials::new(username, pass.clone()), ))); } @@ -168,14 +168,26 @@ impl CredentialStore { /// Retrieve the default/active account credentials. pub fn get_default_credentials() -> Result> { let accounts = Self::list_accounts()?; - let default_id = accounts + if accounts.is_empty() { + return Ok(None); + } + + let default_account = accounts .iter() .find(|a| a.is_default) - .map(|a| a.id.clone()) - .or_else(|| accounts.first().map(|a| a.id.clone())); + .or_else(|| accounts.first()); - match default_id { - Some(id) => Self::get_credentials(&id), + match default_account { + Some(acc) => match Self::get_credentials(&acc.id)? { + Some(res) => Ok(Some(res)), + None => Err(NextcloudError::AuthenticationFailed { + username: acc.username.clone(), + message: format!( + "Account '{}' is registered, but its password was not found in Keychain. Please re-authenticate by running 'nut login {}'.", + acc.id, acc.server_url + ), + }), + }, None => Ok(None), } } @@ -227,10 +239,14 @@ impl CredentialStore { // Try storing password in OS Keyring let mut fallback_password = None; - let keyring_result = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key()) - .and_then(|entry| entry.set_password(app_password)); + let entry_res = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key()); + let keyring_saved = match entry_res { + Ok(ref entry) => entry.set_password(app_password).is_ok(), + Err(_) => false, + }; - if keyring_result.is_err() { + if !keyring_saved { + // Save in fallback password field on failure fallback_password = Some(app_password.to_string()); } new_account.fallback_password = fallback_password; @@ -283,7 +299,7 @@ impl CredentialStore { /// Create an initialized `NextcloudClient` for an account matching `query`. pub fn create_client_for_account(query: &str) -> Result { let (account, creds) = Self::get_credentials(query)?.ok_or_else(|| { - NextcloudError::Other(format!("No credentials found for account '{query}'")) + NextcloudError::Other(format!("No credentials found for account '{query}'. Run 'nut login' to authenticate.")) })?; let config = ClientConfig::new(&account.server_url, Some(creds))?; @@ -293,7 +309,7 @@ impl CredentialStore { /// Create an initialized `NextcloudClient` for the default account. pub fn create_client_for_default() -> Result { let (account, creds) = Self::get_default_credentials()?.ok_or_else(|| { - NextcloudError::Other("No default account configured. Please run 'nut login' or configure an account.".into()) + NextcloudError::Other("No accounts configured. Please run 'nut login ' to connect your Nextcloud instance.".into()) })?; let config = ClientConfig::new(&account.server_url, Some(creds))?; diff --git a/nextcloud_client/src/webdav.rs b/nextcloud_client/src/webdav.rs index 9c59f1a..9dc1f4c 100644 --- a/nextcloud_client/src/webdav.rs +++ b/nextcloud_client/src/webdav.rs @@ -15,6 +15,7 @@ pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024; impl NextcloudClient { /// Upload a stream of bytes to a Nextcloud WebDAV destination with optional progress reporting. + /// Automatically ensures any parent directories exist before uploading. /// /// # Arguments /// * `remote_path` - The destination path on Nextcloud (e.g. `"Uploads/photo.jpg"`). @@ -32,6 +33,14 @@ impl NextcloudClient { S: Stream> + Send + Sync + 'static, E: Into> + 'static, { + // Ensure parent directories exist + let clean_path = remote_path.trim_start_matches('/'); + if let Some((parent_dir, _)) = clean_path.rsplit_once('/') { + if !parent_dir.is_empty() { + self.create_folder_all(parent_dir).await?; + } + } + let url = self.webdav_url(remote_path)?; let progress_stream = ProgressStream::new(stream, content_length, callback); let body = reqwest::Body::wrap_stream(progress_stream); @@ -58,7 +67,7 @@ impl NextcloudClient { }) } else if status == reqwest::StatusCode::CONFLICT { Err(NextcloudError::Other(format!( - "WebDAV Conflict (409) at '{remote_path}'. Ensure the parent directory exists on Nextcloud." + "WebDAV Conflict (409) at '{remote_path}'. Ensure the destination directory exists." ))) } else { let error_text = response.text().await.unwrap_or_default(); @@ -149,4 +158,25 @@ impl NextcloudClient { }) } } + + /// Recursively ensure all parent and subdirectories exist using WebDAV `MKCOL`. + pub async fn create_folder_all(&self, remote_dir: &str) -> Result<()> { + let clean = remote_dir.trim_matches('/'); + if clean.is_empty() { + return Ok(()); + } + + let mut current_path = String::new(); + for segment in clean.split('/') { + if segment.is_empty() { + continue; + } + if !current_path.is_empty() { + current_path.push('/'); + } + current_path.push_str(segment); + self.create_folder(¤t_path).await?; + } + Ok(()) + } }