diff --git a/Tasks.md b/Tasks.md index 8ab556e..9a4b56b 100644 --- a/Tasks.md +++ b/Tasks.md @@ -73,7 +73,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation | | [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature | | [NUT-004](#nut-004) | Implement OCS Share Link Generation | Fixed | Feature | -| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature | +| [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) | Triage | Feature | | [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature | @@ -174,19 +174,19 @@ Implement creation of public share links using the OCS Sharing API. - NUT-002 - + ### Implement Direct Download URL Builder **ID:** NUT-005 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Generate direct-download URLs from share tokens. **Requirements:** -- [ ] Build URL: `/index.php/s//download` -- [ ] Validate token format -- [ ] Provide helper API +- [x] Build URL: `/index.php/s//download` +- [x] Validate token format +- [x] Provide helper API **Dependencies:** - NUT-004 @@ -517,7 +517,7 @@ Tasks progress through defined statuses: 1. `triage`: Under initial evaluation and specification. Missing requirements allowed. 2. `pending`: Scope defined and ready for active work. 3. `in_progress`: Active implementation in progress. -4. `done`: Work complete, requirements checked, and verified. +4. `done`: Fixed. Work complete, requirements checked, and verified. 5. `blocked`: Blocked by an external obstacle or unmet dependency. 6. `cancelled`: Deprecated or abandoned. diff --git a/nextcloud_client/src/client.rs b/nextcloud_client/src/client.rs index 08f293f..1f74ed5 100644 --- a/nextcloud_client/src/client.rs +++ b/nextcloud_client/src/client.rs @@ -109,17 +109,6 @@ impl NextcloudClient { .map_err(NextcloudError::from) } - /// Build the public download URL from a share token. - /// - /// Format: `https:///index.php/s//download` - pub fn direct_download_url(&self, share_token: &str) -> Result { - let path_segment = format!("index.php/s/{share_token}/download"); - self.config - .server_url - .join(&path_segment) - .map_err(NextcloudError::from) - } - /// Check server reachability and retrieve server version info from `/status.php`. /// /// This endpoint does not require authentication. @@ -218,18 +207,6 @@ mod tests { ); } - #[test] - fn test_direct_download_url() { - let config = ClientConfig::new("https://nextcloud.example.com", None).unwrap(); - let client = NextcloudClient::new(config).unwrap(); - let download_url = client.direct_download_url("AbCdEf12345").unwrap(); - - assert_eq!( - download_url.as_str(), - "https://nextcloud.example.com/index.php/s/AbCdEf12345/download" - ); - } - #[test] fn test_status_deserialization() { let sample_json = r#"{ diff --git a/nextcloud_client/src/download.rs b/nextcloud_client/src/download.rs new file mode 100644 index 0000000..f63bf30 --- /dev/null +++ b/nextcloud_client/src/download.rs @@ -0,0 +1,149 @@ +use url::Url; + +use crate::client::NextcloudClient; +use crate::error::{NextcloudError, Result}; + +/// Minimum permitted length for a Nextcloud share token. +pub const MIN_SHARE_TOKEN_LENGTH: usize = 6; + +/// Maximum permitted length for a Nextcloud share token. +pub const MAX_SHARE_TOKEN_LENGTH: usize = 64; + +/// Validates that a share token consists only of valid alphanumeric or safe characters (`[a-zA-Z0-9_-]`). +/// +/// Returns `Ok(())` if valid, or a [`NextcloudError::Other`] describing the validation failure. +pub fn validate_share_token(token: &str) -> Result<()> { + let trimmed = token.trim(); + + if trimmed.is_empty() { + return Err(NextcloudError::Other("Share token cannot be empty".into())); + } + + if trimmed.len() < MIN_SHARE_TOKEN_LENGTH || trimmed.len() > MAX_SHARE_TOKEN_LENGTH { + return Err(NextcloudError::Other(format!( + "Invalid share token length ({}): expected between {} and {} characters", + trimmed.len(), + MIN_SHARE_TOKEN_LENGTH, + MAX_SHARE_TOKEN_LENGTH + ))); + } + + if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err(NextcloudError::Other(format!( + "Invalid characters in share token '{token}': tokens must only contain alphanumeric characters, hyphens, or underscores" + ))); + } + + Ok(()) +} + +/// Builds a direct download URL for a shared file given a base Nextcloud URL and share token. +/// +/// Format: `https:///index.php/s//download` +pub fn build_direct_download_url(base_url: &Url, share_token: &str) -> Result { + validate_share_token(share_token)?; + let clean_token = share_token.trim(); + let path_segment = format!("index.php/s/{clean_token}/download"); + base_url.join(&path_segment).map_err(NextcloudError::from) +} + +/// Builds a direct download URL for a specific file within a shared folder. +/// +/// Nextcloud supports downloading an individual file from a shared directory via query parameters: +/// `https:///index.php/s//download?path=%2F&files=filename.ext` +pub fn build_subfile_download_url( + base_url: &Url, + share_token: &str, + subfile_path: &str, +) -> Result { + let mut url = build_direct_download_url(base_url, share_token)?; + + let clean_path = subfile_path.trim_start_matches('/'); + if let Some((dir, file)) = clean_path.rsplit_once('/') { + let root_dir = format!("/{dir}"); + url.query_pairs_mut() + .append_pair("path", &root_dir) + .append_pair("files", file); + } else { + url.query_pairs_mut() + .append_pair("path", "/") + .append_pair("files", clean_path); + } + + Ok(url) +} + +impl NextcloudClient { + /// Generate a validated direct download URL from a share token. + pub fn direct_download_url(&self, share_token: &str) -> Result { + build_direct_download_url(&self.config.server_url, share_token) + } + + /// Generate a validated direct download URL for a specific sub-file in a shared folder. + pub fn subfile_download_url(&self, share_token: &str, subfile_path: &str) -> Result { + build_subfile_download_url(&self.config.server_url, share_token, subfile_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_tokens() { + assert!(validate_share_token("AbCdEf123456789").is_ok()); + assert!(validate_share_token("aB1_2-3z").is_ok()); + assert!(validate_share_token("kLx9Q2wP0vN4m1R").is_ok()); + } + + #[test] + fn test_invalid_tokens() { + // Empty + assert!(validate_share_token("").is_err()); + // Too short (< 6 chars) + assert!(validate_share_token("abc").is_err()); + // Too long (> 64 chars) + assert!(validate_share_token(&"a".repeat(65)).is_err()); + // Path traversal / slashes + assert!(validate_share_token("../secret").is_err()); + assert!(validate_share_token("abc/def").is_err()); + // Spaces & query chars + assert!(validate_share_token("abc def").is_err()); + assert!(validate_share_token("abc?download=1").is_err()); + assert!(validate_share_token("abc#frag").is_err()); + } + + #[test] + fn test_build_direct_download_url() { + let base = Url::parse("https://cloud.example.com/").unwrap(); + let download_url = build_direct_download_url(&base, "kLx9Q2wP0vN4m1R").unwrap(); + + assert_eq!( + download_url.as_str(), + "https://cloud.example.com/index.php/s/kLx9Q2wP0vN4m1R/download" + ); + } + + #[test] + fn test_build_subpath_installation() { + let base = Url::parse("https://example.com/nextcloud/").unwrap(); + let download_url = build_direct_download_url(&base, "kLx9Q2wP0vN4m1R").unwrap(); + + assert_eq!( + download_url.as_str(), + "https://example.com/nextcloud/index.php/s/kLx9Q2wP0vN4m1R/download" + ); + } + + #[test] + fn test_build_subfile_download_url() { + let base = Url::parse("https://cloud.example.com/").unwrap(); + let subfile_url = + build_subfile_download_url(&base, "kLx9Q2wP0vN4m1R", "archive/document.pdf").unwrap(); + + assert_eq!( + subfile_url.as_str(), + "https://cloud.example.com/index.php/s/kLx9Q2wP0vN4m1R/download?path=%2Farchive&files=document.pdf" + ); + } +} diff --git a/nextcloud_client/src/lib.rs b/nextcloud_client/src/lib.rs index 8293657..da30533 100644 --- a/nextcloud_client/src/lib.rs +++ b/nextcloud_client/src/lib.rs @@ -7,6 +7,7 @@ pub mod auth; pub mod client; pub mod config; pub mod credentials; +pub mod download; pub mod error; pub mod models; pub mod progress; @@ -21,6 +22,10 @@ pub use auth::{ pub use client::{NextcloudClient, ServerStatus}; pub use config::{AccountCredentials, ClientConfig}; pub use credentials::{CredentialStore, StoredAccount, KEYRING_SERVICE_NAME}; +pub use download::{ + build_direct_download_url, build_subfile_download_url, validate_share_token, + MAX_SHARE_TOKEN_LENGTH, MIN_SHARE_TOKEN_LENGTH, +}; pub use error::{NextcloudError, Result}; pub use models::{UploadOptions, UploadResult}; pub use progress::{ProgressCallback, ProgressEvent};