Adds basic config api and constants to the library.
This commit is contained in:
252
Cargo.lock
generated
252
Cargo.lock
generated
@@ -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"
|
||||
|
||||
31
Tasks.md
31
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
|
||||
|
||||
|
||||
<a id="nut-002" class="task" data-status="triage" data-task-type="foundation"></a>
|
||||
<a id="nut-002" class="task" data-status="done" data-task-type="foundation"></a>
|
||||
### 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: `<a id=\"nut-001\" class=\"task\" data-status=\"pending\" data-task-type=\"foundation\"></a>`.
|
||||
- Anchor IDs are lowercase: `<a id="nut-001" class="task" data-status="pending" data-task-type="foundation"></a>`.
|
||||
- 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]`.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
216
nextcloud_client/src/client.rs
Normal file
216
nextcloud_client/src/client.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
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://<host>/remote.php/dav/files/<username>/<path>`
|
||||
pub fn webdav_url(&self, remote_path: &str) -> Result<Url> {
|
||||
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://<host>/ocs/v2.php/apps/files_sharing/api/v1/shares`
|
||||
pub fn ocs_shares_url(&self) -> Result<Url> {
|
||||
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://<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`.
|
||||
///
|
||||
/// This endpoint does not require authentication.
|
||||
pub async fn check_status(&self) -> Result<ServerStatus> {
|
||||
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::<ServerStatus>().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"));
|
||||
}
|
||||
}
|
||||
135
nextcloud_client/src/config.rs
Normal file
135
nextcloud_client/src/config.rs
Normal file
@@ -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<String>, app_password: impl Into<String>) -> 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<AccountCredentials>,
|
||||
|
||||
/// 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<S>(duration: &Duration, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_u64(duration.as_secs())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
|
||||
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<AccountCredentials>,
|
||||
) -> Result<Self> {
|
||||
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<String>) -> 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());
|
||||
}
|
||||
}
|
||||
38
nextcloud_client/src/error.rs
Normal file
38
nextcloud_client/src/error.rs
Normal file
@@ -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<T, NextcloudError>`.
|
||||
pub type Result<T> = std::result::Result<T, NextcloudError>;
|
||||
@@ -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};
|
||||
|
||||
64
nextcloud_client/src/models.rs
Normal file
64
nextcloud_client/src/models.rs
Normal file
@@ -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<String>,
|
||||
|
||||
/// 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<String>) -> 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<String>) -> 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user