Implements the download url builder.
This commit is contained in:
14
Tasks.md
14
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-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.
|
||||||
|
|
||||||
|
|||||||
@@ -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#"{
|
||||||
|
|||||||
149
nextcloud_client/src/download.rs
Normal file
149
nextcloud_client/src/download.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user