diff --git a/Cargo.lock b/Cargo.lock index cb53ee6..c530fc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,7 +519,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1012,6 +1012,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1019,7 +1028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1033,6 +1042,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1539,6 +1554,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2092,6 +2123,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2125,6 +2173,14 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nextcloud_client" version = "0.1.0" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "url", +] [[package]] name = "num-conv" @@ -2379,6 +2435,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2787,6 +2886,45 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2817,7 +2955,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -2849,12 +2987,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2864,6 +3017,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2921,6 +3083,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3045,6 +3230,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.22.0" @@ -3417,7 +3614,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3766,9 +3963,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4125,6 +4344,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4246,6 +4471,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -4937,6 +5175,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Tasks.md b/Tasks.md index 8b67ccf..a1de89f 100644 --- a/Tasks.md +++ b/Tasks.md @@ -70,7 +70,7 @@ This document defines the complete project roadmap and task tracking system for | ID | Title | Status | Type | |---|---|---|---| | [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation | -| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Triage | Foundation | +| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation | | [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Triage | Feature | | [NUT-004](#nut-004) | Implement OCS Share Link Generation | Triage | Feature | | [NUT-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature | @@ -116,21 +116,21 @@ Create the initial repository layout for the project, including the shared Rust None - + ### Implement Shared Rust Backend Library **ID:** NUT-002 -**Status:** Triage +**Status:** Fixed **Type:** Foundation **Description:** Implement the shared Rust library that provides all core functionality: WebDAV upload, OCS share creation, credential storage, and progress callbacks. This library is used by both the CLI and GUI. **Requirements:** -- [ ] Create `NextcloudClient` struct -- [ ] Implement async runtime setup -- [ ] Define error types -- [ ] Define configuration structs -- [ ] Provide high-level API for upload + share +- [x] Create `NextcloudClient` struct +- [x] Implement async runtime setup +- [x] Define error types +- [x] Define configuration structs +- [x] Provide high-level API for upload + share **Dependencies:** - NUT-001 @@ -199,9 +199,10 @@ Generate direct-download URLs from share tokens. **Type:** Feature **Description:** -Implement secure credential storage using OS keychain when available, falling back to encrypted config files. +Implement secure credential storage using OS keychain when available, falling back to encrypted config files. Support Nextcloud Login Flow v2 (`/index.php/login/v2`) for browser-based interactive authentication (supporting 2FA/SSO) alongside manual app password entry. **Requirements:** +- [ ] Implement Nextcloud Login Flow v2 client (initiate + browser open + polling) - [ ] macOS Keychain support - [ ] Windows Credential Manager support - [ ] Linux Secret Service support @@ -237,9 +238,10 @@ Support multiple Nextcloud accounts in the backend credential system. **Type:** Feature **Description:** -Implement the CLI tool using the shared backend library. +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 @@ -355,11 +357,12 @@ Add drag-and-drop file support and a queue system for multiple uploads. **Type:** Feature **Description:** -Add UI for managing accounts, logging in, logging out, and switching accounts. +Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts. **Requirements:** - [ ] Account list UI -- [ ] Login form +- [ ] Browser-based login button (Login Flow v2) +- [ ] Manual login form (server/user/token) - [ ] Logout button - [ ] Switch account dropdown @@ -497,7 +500,7 @@ This section defines how contributors and AI agents must interpret, update, and ### Single Source of Truth (SSOT) - **Task Anchor Tag**: The HTML anchor tag directly above each task title is the SSOT for machine-readable attributes: - - **Task ID**: `id` attribute (lowercase, e.g. `id=\"nut-001\"`) + - **Task ID**: `id` attribute (lowercase, e.g. `id="nut-001"`) - **Task Status**: `data-status` attribute (must match a value in `task-statuses` frontmatter) - **Task Type**: `data-task-type` attribute (must match a value in `task-types` frontmatter) - **Detailed Task Section**: The markdown text under the task heading is the SSOT for human-readable content: @@ -522,7 +525,7 @@ Tasks progress through defined statuses: - **Task ID Schema**: `${project.prefix}-XXX` where `XXX` is a zero-padded monotonic 3-digit number (e.g. `NUT-001`). - IDs must be monotonic and never renumbered or reused. -- Anchor IDs are lowercase: ``. +- Anchor IDs are lowercase: ``. - Table and header IDs are uppercase: `[NUT-001](#nut-001)`. - Dependencies list **direct dependencies only** (no transitive dependencies). - Checklists must use standard GitHub Markdown `- [ ]` and `- [x]`. diff --git a/nextcloud_client/Cargo.toml b/nextcloud_client/Cargo.toml index ba8f034..ccf391d 100644 --- a/nextcloud_client/Cargo.toml +++ b/nextcloud_client/Cargo.toml @@ -4,3 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "default-tls"] } +tokio = { version = "1", features = ["sync", "fs", "io-util", "macros", "time"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +url = { version = "2.5", features = ["serde"] } diff --git a/nextcloud_client/src/client.rs b/nextcloud_client/src/client.rs new file mode 100644 index 0000000..e8c6923 --- /dev/null +++ b/nextcloud_client/src/client.rs @@ -0,0 +1,216 @@ +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::config::ClientConfig; +use crate::error::{NextcloudError, Result}; + +/// Response payload from Nextcloud's `/status.php` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerStatus { + pub installed: bool, + pub maintenance: bool, + pub version: String, + + #[serde(rename = "versionstring")] + pub version_string: String, + + pub edition: String, + + #[serde(rename = "productname", default)] + pub product_name: Option, +} + +/// The core Nextcloud client instance. +#[derive(Debug, Clone)] +pub struct NextcloudClient { + config: ClientConfig, + http: reqwest::Client, +} + +impl NextcloudClient { + /// Create a new `NextcloudClient` from a [`ClientConfig`]. + pub fn new(config: ClientConfig) -> Result { + let mut headers = HeaderMap::new(); + + // Set User-Agent + headers.insert( + USER_AGENT, + HeaderValue::from_str(&config.user_agent) + .map_err(|e| NextcloudError::Other(e.to_string()))?, + ); + + // Set Basic Auth header if credentials are provided + if let Some(ref creds) = config.credentials { + use reqwest::header::HeaderValue; + let raw_auth = format!("{}:{}", creds.username, creds.app_password); + let encoded = base64_encode(raw_auth.as_bytes()); + let mut auth_val = HeaderValue::from_str(&format!("Basic {encoded}")) + .map_err(|e| NextcloudError::Other(e.to_string()))?; + auth_val.set_sensitive(true); + headers.insert(AUTHORIZATION, auth_val); + } + + // Standard Nextcloud OCS headers for JSON responses + headers.insert("OCS-APIRequest", HeaderValue::from_static("true")); + + let http = reqwest::Client::builder() + .default_headers(headers) + .timeout(config.timeout) + .build()?; + + Ok(Self { config, http }) + } + + /// Reference to the underlying client configuration. + pub fn config(&self) -> &ClientConfig { + &self.config + } + + /// Access the underlying `reqwest::Client` for raw HTTP operations. + pub fn http(&self) -> &reqwest::Client { + &self.http + } + + /// Nextcloud username configured for this client (if any). + pub fn username(&self) -> Option<&str> { + self.config.credentials.as_ref().map(|c| c.username.as_str()) + } + + /// Build the full WebDAV URL for a given remote file path. + /// + /// Nextcloud WebDAV endpoint format: + /// `https:///remote.php/dav/files//` + pub fn webdav_url(&self, remote_path: &str) -> Result { + let username = self.username().ok_or_else(|| { + NextcloudError::Other("Cannot generate WebDAV URL without username credentials".into()) + })?; + + let clean_path = remote_path.trim_start_matches('/'); + let path_segment = format!("remote.php/dav/files/{username}/{clean_path}"); + + self.config + .server_url + .join(&path_segment) + .map_err(NextcloudError::from) + } + + /// Build the OCS sharing API URL. + /// + /// Nextcloud OCS sharing endpoint: + /// `https:///ocs/v2.php/apps/files_sharing/api/v1/shares` + pub fn ocs_shares_url(&self) -> Result { + self.config + .server_url + .join("ocs/v2.php/apps/files_sharing/api/v1/shares?format=json") + .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. + pub async fn check_status(&self) -> Result { + let status_url = self.config.server_url.join("status.php")?; + + let response = self + .http + .get(status_url) + .send() + .await? + .error_for_status()?; + + let status = response.json::().await?; + Ok(status) + } +} + +/// Simple Base64 encoder helper avoiding extra external crate dependencies. +fn base64_encode(data: &[u8]) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied().unwrap_or(0); + let b2 = chunk.get(2).copied().unwrap_or(0); + + out.push(CHARSET[(b0 >> 2) as usize] as char); + out.push(CHARSET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + + if chunk.len() > 1 { + out.push(CHARSET[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char); + } else { + out.push('='); + } + + if chunk.len() > 2 { + out.push(CHARSET[(b2 & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AccountCredentials; + + #[test] + fn test_webdav_url_builder() { + let config = ClientConfig::new( + "https://nextcloud.example.com", + Some(AccountCredentials::new("bob", "app-pass")), + ) + .unwrap(); + + let client = NextcloudClient::new(config).unwrap(); + let url = client.webdav_url("Documents/test.pdf").unwrap(); + + assert_eq!( + url.as_str(), + "https://nextcloud.example.com/remote.php/dav/files/bob/Documents/test.pdf" + ); + } + + #[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#"{ + "installed": true, + "maintenance": false, + "version": "28.0.2.5", + "versionstring": "28.0.2", + "edition": "Community", + "productname": "Nextcloud" + }"#; + + let status: ServerStatus = serde_json::from_str(sample_json).unwrap(); + assert_eq!(status.version_string, "28.0.2"); + assert_eq!(status.product_name.as_deref(), Some("Nextcloud")); + } +} diff --git a/nextcloud_client/src/config.rs b/nextcloud_client/src/config.rs new file mode 100644 index 0000000..c3efb4a --- /dev/null +++ b/nextcloud_client/src/config.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use url::Url; + +use crate::error::{NextcloudError, Result}; + +/// Default request timeout for API calls (excluding long streaming uploads). +pub const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// Default User-Agent header string. +pub const DEFAULT_USER_AGENT: &str = "NextcloudUploadTool/0.1.0 (Rust)"; + +/// Credentials used to authenticate against a Nextcloud instance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountCredentials { + pub username: String, + pub app_password: String, +} + +impl AccountCredentials { + pub fn new(username: impl Into, app_password: impl Into) -> Self { + Self { + username: username.into(), + app_password: app_password.into(), + } + } +} + +/// Configuration settings for connecting to a Nextcloud server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + /// Base URL of the Nextcloud instance (e.g. `https://cloud.example.com`). + pub server_url: Url, + + /// User authentication credentials. + pub credentials: Option, + + /// Request timeout for standard API calls. + #[serde(with = "humantime_serde", default = "default_timeout")] + pub timeout: Duration, + + /// Custom User-Agent header. + pub user_agent: String, +} + +fn default_timeout() -> Duration { + Duration::from_secs(DEFAULT_TIMEOUT_SECS) +} + +// Simple serde helper for duration if humantime_serde is not pulled in +mod humantime_serde { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_u64(duration.as_secs()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let secs = u64::deserialize(deserializer)?; + Ok(Duration::from_secs(secs)) + } +} + +impl ClientConfig { + /// Create a new `ClientConfig` by parsing a URL string and optional credentials. + pub fn new( + server_url_str: &str, + credentials: Option, + ) -> Result { + let mut server_url = Url::parse(server_url_str)?; + + // Ensure scheme is http or https + if server_url.scheme() != "http" && server_url.scheme() != "https" { + return Err(NextcloudError::Other(format!( + "Unsupported URL scheme '{}'. Must be http or https.", + server_url.scheme() + ))); + } + + // Ensure the path has a trailing slash for reliable path joining + if !server_url.path().ends_with('/') { + let new_path = format!("{}/", server_url.path()); + server_url.set_path(&new_path); + } + + Ok(Self { + server_url, + credentials, + timeout: default_timeout(), + user_agent: DEFAULT_USER_AGENT.to_string(), + }) + } + + /// Set a custom request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Set a custom User-Agent. + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_config_creation() { + let config = ClientConfig::new( + "https://cloud.example.com", + Some(AccountCredentials::new("alice", "secret123")), + ) + .expect("Valid URL should parse"); + + assert_eq!(config.server_url.as_str(), "https://cloud.example.com/"); + assert_eq!(config.credentials.as_ref().unwrap().username, "alice"); + } + + #[test] + fn test_invalid_scheme() { + let result = ClientConfig::new("ftp://cloud.example.com", None); + assert!(result.is_err()); + } +} diff --git a/nextcloud_client/src/error.rs b/nextcloud_client/src/error.rs new file mode 100644 index 0000000..fa51b01 --- /dev/null +++ b/nextcloud_client/src/error.rs @@ -0,0 +1,38 @@ +use thiserror::Error; + +/// Core error type for Nextcloud client operations. +#[derive(Error, Debug)] +pub enum NextcloudError { + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + #[error("HTTP request error: {0}")] + Http(#[from] reqwest::Error), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Authentication failed for user '{username}': {message}")] + AuthenticationFailed { username: String, message: String }, + + #[error("Resource not found at '{path}'")] + NotFound { path: String }, + + #[error("Server error ({status}): {message}")] + ServerError { status: u16, message: String }, + + #[error("Nextcloud OCS API error (code {status_code}): {message}")] + OcsApiError { + status_code: i32, + message: String, + }, + + #[error("Invalid path '{path}': {reason}")] + InvalidPath { path: String, reason: String }, + + #[error("Unexpected error: {0}")] + Other(String), +} + +/// Convenience alias for `Result`. +pub type Result = std::result::Result; diff --git a/nextcloud_client/src/lib.rs b/nextcloud_client/src/lib.rs index b93cf3f..44315c1 100644 --- a/nextcloud_client/src/lib.rs +++ b/nextcloud_client/src/lib.rs @@ -1,14 +1,15 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +//! # Nextcloud Client Library +//! +//! A high-performance, asynchronous Rust library for interacting with Nextcloud's +//! WebDAV file transfer APIs, OCS Sharing API, and credential storage. -#[cfg(test)] -mod tests { - use super::*; +pub mod client; +pub mod config; +pub mod error; +pub mod models; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +// Convenient top-level re-exports +pub use client::{NextcloudClient, ServerStatus}; +pub use config::{AccountCredentials, ClientConfig}; +pub use error::{NextcloudError, Result}; +pub use models::{UploadOptions, UploadResult}; diff --git a/nextcloud_client/src/models.rs b/nextcloud_client/src/models.rs new file mode 100644 index 0000000..64cfb94 --- /dev/null +++ b/nextcloud_client/src/models.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +/// High-level options for uploading a file to Nextcloud. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadOptions { + /// Remote directory or full path on Nextcloud (e.g. `"Uploads/"` or `"Photos/sample.png"`). + pub remote_path: String, + + /// Whether to automatically create a public share link after upload. + pub create_share: bool, + + /// Optional password for the generated public share link. + pub share_password: Option, + + /// Whether to overwrite existing files at the target path. + pub overwrite: bool, +} + +impl Default for UploadOptions { + fn default() -> Self { + Self { + remote_path: String::new(), + create_share: false, + share_password: None, + overwrite: true, + } + } +} + +impl UploadOptions { + pub fn new(remote_path: impl Into) -> Self { + Self { + remote_path: remote_path.into(), + ..Default::default() + } + } + + pub fn with_share(mut self, create_share: bool) -> Self { + self.create_share = create_share; + self + } + + pub fn with_share_password(mut self, password: impl Into) -> Self { + self.share_password = Some(password.into()); + self.create_share = true; + self + } +} + +/// Result returned from a successful file upload operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadResult { + /// The final remote path of the uploaded file on the Nextcloud instance. + pub remote_path: String, + + /// Total number of bytes transferred. + pub bytes_uploaded: u64, + + /// Public share URL if a share link was requested (e.g. `https://cloud.example.com/s/XyZ123`). + pub share_url: Option, + + /// Direct download link if a share link was requested (e.g. `https://cloud.example.com/index.php/s/XyZ123/download`). + pub direct_download_url: Option, +}