Implements cli uploads and link generation.

This commit is contained in:
2026-08-23 17:08:21 -07:00
parent e4ccd62072
commit c6ad520797
8 changed files with 702 additions and 37 deletions

264
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
<a id="nut-008" class="task" data-status="triage" data-task-type="feature"></a>
<a id="nut-008" class="task" data-status="done" data-task-type="feature"></a>
### 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

View File

@@ -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"

View File

@@ -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 <headhunter3@gmail.com>",
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<String>,
/// 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<PathBuf>,
/// Specific account identifier or label to use (defaults to active account)
#[arg(short, long)]
account: Option<String>,
/// 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<String>,
/// 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 <FILE>... 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 <SERVER_URL>\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(())
}

View File

@@ -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"

View File

@@ -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<AccountCredentials>,
) -> Result<Self> {
/// Normalize a server URL string: validates http/https scheme and ensures a trailing slash.
pub fn normalize_url(server_url_str: &str) -> Result<Url> {
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<AccountCredentials>,
) -> Result<Self> {
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);

View File

@@ -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<String>,
}
@@ -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<Option<(StoredAccount, AccountCredentials)>> {
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<NextcloudClient> {
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<NextcloudClient> {
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 <server_url>' to connect your Nextcloud instance.".into())
})?;
let config = ClientConfig::new(&account.server_url, Some(creds))?;

View File

@@ -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<Item = std::result::Result<Bytes, E>> + Send + Sync + 'static,
E: Into<Box<dyn std::error::Error + Send + Sync>> + '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(&current_path).await?;
}
Ok(())
}
}