Implements the download url builder.

This commit is contained in:
2026-08-23 15:17:03 -07:00
parent 2e8ddd3a73
commit 81f4423cfe
4 changed files with 161 additions and 30 deletions

View File

@@ -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-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation |
| [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature | | [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature |
| [NUT-004](#nut-004) | Implement OCS Share Link Generation | 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-006](#nut-006) | Implement Credential Storage System | Fixed | Feature |
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Triage | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Triage | Feature |
| [NUT-008](#nut-008) | Implement CLI Frontend | 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 - NUT-002
<a id="nut-005" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-005" class="task" data-status="done" data-task-type="feature"></a>
### Implement Direct Download URL Builder ### Implement Direct Download URL Builder
**ID:** NUT-005 **ID:** NUT-005
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Generate direct-download URLs from share tokens. Generate direct-download URLs from share tokens.
**Requirements:** **Requirements:**
- [ ] Build URL: `/index.php/s/<token>/download` - [x] Build URL: `/index.php/s/<token>/download`
- [ ] Validate token format - [x] Validate token format
- [ ] Provide helper API - [x] Provide helper API
**Dependencies:** **Dependencies:**
- NUT-004 - NUT-004
@@ -517,7 +517,7 @@ Tasks progress through defined statuses:
1. `triage`: Under initial evaluation and specification. Missing requirements allowed. 1. `triage`: Under initial evaluation and specification. Missing requirements allowed.
2. `pending`: Scope defined and ready for active work. 2. `pending`: Scope defined and ready for active work.
3. `in_progress`: Active implementation in progress. 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. 5. `blocked`: Blocked by an external obstacle or unmet dependency.
6. `cancelled`: Deprecated or abandoned. 6. `cancelled`: Deprecated or abandoned.

View File

@@ -109,17 +109,6 @@ impl NextcloudClient {
.map_err(NextcloudError::from) .map_err(NextcloudError::from)
} }
/// Build the public download URL from a share token.
///
/// Format: `https://<host>/index.php/s/<token>/download`
pub fn direct_download_url(&self, share_token: &str) -> Result<Url> {
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`. /// Check server reachability and retrieve server version info from `/status.php`.
/// ///
/// This endpoint does not require authentication. /// 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] #[test]
fn test_status_deserialization() { fn test_status_deserialization() {
let sample_json = r#"{ let sample_json = r#"{

View File

@@ -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://<host>/index.php/s/<token>/download`
pub fn build_direct_download_url(base_url: &Url, share_token: &str) -> Result<Url> {
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://<host>/index.php/s/<token>/download?path=%2F&files=filename.ext`
pub fn build_subfile_download_url(
base_url: &Url,
share_token: &str,
subfile_path: &str,
) -> Result<Url> {
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<Url> {
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<Url> {
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"
);
}
}

View File

@@ -7,6 +7,7 @@ pub mod auth;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod credentials; pub mod credentials;
pub mod download;
pub mod error; pub mod error;
pub mod models; pub mod models;
pub mod progress; pub mod progress;
@@ -21,6 +22,10 @@ pub use auth::{
pub use client::{NextcloudClient, ServerStatus}; pub use client::{NextcloudClient, ServerStatus};
pub use config::{AccountCredentials, ClientConfig}; pub use config::{AccountCredentials, ClientConfig};
pub use credentials::{CredentialStore, StoredAccount, KEYRING_SERVICE_NAME}; 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 error::{NextcloudError, Result};
pub use models::{UploadOptions, UploadResult}; pub use models::{UploadOptions, UploadResult};
pub use progress::{ProgressCallback, ProgressEvent}; pub use progress::{ProgressCallback, ProgressEvent};