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

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(())
}
}