From 691c4a7d8054e00f826409f51fed4f96b9170569 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 23 Aug 2026 06:24:41 -0700 Subject: [PATCH] Implements share link generation. --- Tasks.md | 14 +- nextcloud_client/src/client.rs | 32 ++++ nextcloud_client/src/lib.rs | 2 + nextcloud_client/src/sharing.rs | 278 ++++++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 nextcloud_client/src/sharing.rs diff --git a/Tasks.md b/Tasks.md index c6ddbf4..b088916 100644 --- a/Tasks.md +++ b/Tasks.md @@ -72,7 +72,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation | | [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 | Triage | Feature | +| [NUT-004](#nut-004) | Implement OCS Share Link Generation | Fixed | Feature | | [NUT-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature | | [NUT-006](#nut-006) | Implement Credential Storage System | Triage | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Triage | Feature | @@ -155,20 +155,20 @@ Implement file upload using Nextcloud’s WebDAV API. Support streaming uploads, - NUT-002 - + ### Implement OCS Share Link Generation **ID:** NUT-004 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Implement creation of public share links using the OCS Sharing API. **Requirements:** -- [ ] POST to `/ocs/v2.php/apps/files_sharing/api/v1/shares` -- [ ] Parse JSON/XML response -- [ ] Extract share token -- [ ] Return share metadata +- [x] POST to `/ocs/v2.php/apps/files_sharing/api/v1/shares` +- [x] Parse JSON/XML response +- [x] Extract share token +- [x] Return share metadata **Dependencies:** - NUT-002 diff --git a/nextcloud_client/src/client.rs b/nextcloud_client/src/client.rs index 4cf645a..08f293f 100644 --- a/nextcloud_client/src/client.rs +++ b/nextcloud_client/src/client.rs @@ -1,9 +1,12 @@ use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; use serde::{Deserialize, Serialize}; +use std::path::Path; use url::Url; use crate::config::ClientConfig; use crate::error::{NextcloudError, Result}; +use crate::models::{UploadOptions, UploadResult}; +use crate::progress::ProgressCallback; /// Response payload from Nextcloud's `/status.php` endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -133,6 +136,35 @@ impl NextcloudClient { let status = response.json::().await?; Ok(status) } + + /// High-level orchestration method that uploads a file from disk and optionally generates a public share link. + pub async fn upload_and_share>( + &self, + local_path: P, + options: &UploadOptions, + progress: Option, + ) -> Result { + let bytes_uploaded = self + .upload_file(local_path, &options.remote_path, progress) + .await?; + + let (share_url, direct_download_url) = if options.create_share { + let share = self + .create_public_share(&options.remote_path, options.share_password.as_deref()) + .await?; + let direct = self.direct_download_url(&share.token)?.to_string(); + (Some(share.url), Some(direct)) + } else { + (None, None) + }; + + Ok(UploadResult { + remote_path: options.remote_path.clone(), + bytes_uploaded, + share_url, + direct_download_url, + }) + } } /// Simple Base64 encoder helper avoiding extra external crate dependencies. diff --git a/nextcloud_client/src/lib.rs b/nextcloud_client/src/lib.rs index 017baf1..16b52c6 100644 --- a/nextcloud_client/src/lib.rs +++ b/nextcloud_client/src/lib.rs @@ -8,6 +8,7 @@ pub mod config; pub mod error; pub mod models; pub mod progress; +pub mod sharing; pub mod webdav; // Convenient top-level re-exports @@ -16,3 +17,4 @@ pub use config::{AccountCredentials, ClientConfig}; pub use error::{NextcloudError, Result}; pub use models::{UploadOptions, UploadResult}; pub use progress::{ProgressCallback, ProgressEvent}; +pub use sharing::{CreateShareParams, ShareInfo, ShareType}; diff --git a/nextcloud_client/src/sharing.rs b/nextcloud_client/src/sharing.rs new file mode 100644 index 0000000..819d7b3 --- /dev/null +++ b/nextcloud_client/src/sharing.rs @@ -0,0 +1,278 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::client::NextcloudClient; +use crate::error::{NextcloudError, Result}; + +/// Nextcloud OCS Share Type enumeration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum ShareType { + User = 0, + Group = 1, + PublicLink = 3, + Email = 4, + Federated = 6, + Circle = 7, + Room = 10, +} + +impl Default for ShareType { + fn default() -> Self { + Self::PublicLink + } +} + +/// Request parameters for creating a new share via the Nextcloud OCS API. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CreateShareParams { + /// Remote path of the file or folder to share (e.g. `"Uploads/photo.jpg"`). + pub path: String, + + /// Type of share (defaults to `ShareType::PublicLink`). + pub share_type: ShareType, + + /// Optional password to protect a public share link. + pub password: Option, + + /// Bitmask permissions (e.g. 1 = Read, 31 = All). + pub permissions: Option, + + /// Optional expiration date in `YYYY-MM-DD` format. + pub expire_date: Option, + + /// Optional note for the recipient. + pub note: Option, + + /// Optional custom label for public links. + pub label: Option, +} + +impl CreateShareParams { + /// Create parameters for a public link share. + pub fn public_link(path: impl Into) -> Self { + Self { + path: path.into(), + share_type: ShareType::PublicLink, + ..Default::default() + } + } + + /// Set an optional password for the public share. + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// Set an optional expiration date (`YYYY-MM-DD`). + pub fn with_expire_date(mut self, expire_date: impl Into) -> Self { + self.expire_date = Some(expire_date.into()); + self + } +} + +/// Metadata returned for an active Nextcloud share. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShareInfo { + /// Unique identifier for the share on the Nextcloud instance. + pub id: String, + + /// Share type (3 = Public Link). + pub share_type: u8, + + /// Username of the share owner. + #[serde(default)] + pub uid_owner: String, + + /// Public URL for the share link (e.g. `https://cloud.example.com/s/XyZ123`). + #[serde(default)] + pub url: String, + + /// Unique share token (e.g. `XyZ123`). + #[serde(default)] + pub token: String, + + /// Remote path of the shared resource. + #[serde(default)] + pub path: String, + + /// Bitmask permissions on the share. + #[serde(default)] + pub permissions: u32, + + /// Expiration timestamp or formatted date string if set. + pub expiration: Option, +} + +// Internal OCS envelope format +#[derive(Debug, Deserialize)] +struct OcsEnvelope { + ocs: OcsPayload, +} + +#[derive(Debug, Deserialize)] +struct OcsPayload { + meta: OcsMeta, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct OcsMeta { + #[serde(default)] + status: String, + statuscode: i32, + message: Option, +} + +impl NextcloudClient { + /// Create a new share using the Nextcloud OCS Sharing API. + /// + /// Endpoint: `POST /ocs/v2.php/apps/files_sharing/api/v1/shares` + pub async fn create_share(&self, params: CreateShareParams) -> Result { + let shares_url = self.ocs_shares_url()?; + + let mut form = HashMap::new(); + // Ensure path begins with '/' for Nextcloud OCS sharing API + let normalized_path = if params.path.starts_with('/') { + params.path + } else { + format!("/{}", params.path) + }; + + form.insert("path", normalized_path); + let share_type_str = (params.share_type as u8).to_string(); + form.insert("shareType", share_type_str); + + if let Some(ref pass) = params.password { + form.insert("password", pass.clone()); + } + if let Some(perms) = params.permissions { + form.insert("permissions", perms.to_string()); + } + if let Some(ref expire) = params.expire_date { + form.insert("expireDate", expire.clone()); + } + if let Some(ref note) = params.note { + form.insert("note", note.clone()); + } + if let Some(ref label) = params.label { + form.insert("label", label.clone()); + } + + let response = self + .http + .post(shares_url) + .form(&form) + .send() + .await? + .error_for_status()?; + + let envelope: OcsEnvelope = response.json().await?; + + // OCS statuscode 200 = Success in OCS v2 + if envelope.ocs.meta.statuscode == 200 || envelope.ocs.meta.statuscode == 100 { + envelope.ocs.data.ok_or_else(|| { + NextcloudError::Other("OCS API returned success status but missing data payload".into()) + }) + } else { + Err(NextcloudError::OcsApiError { + status_code: envelope.ocs.meta.statuscode, + message: envelope + .ocs + .meta + .message + .unwrap_or_else(|| envelope.ocs.meta.status), + }) + } + } + + /// Convenience helper to create a public link share for a remote path. + pub async fn create_public_share( + &self, + remote_path: &str, + password: Option<&str>, + ) -> Result { + let mut params = CreateShareParams::public_link(remote_path); + if let Some(pass) = password { + params = params.with_password(pass); + } + self.create_share(params).await + } + + /// Delete an existing share by share ID. + /// + /// Endpoint: `DELETE /ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` + pub async fn delete_share(&self, share_id: &str) -> Result<()> { + let delete_path = format!( + "ocs/v2.php/apps/files_sharing/api/v1/shares/{}?format=json", + share_id + ); + let url = self.config.server_url.join(&delete_path)?; + + let response = self.http.delete(url).send().await?.error_for_status()?; + + let envelope: OcsEnvelope = response.json().await?; + if envelope.ocs.meta.statuscode == 200 || envelope.ocs.meta.statuscode == 100 { + Ok(()) + } else { + Err(NextcloudError::OcsApiError { + status_code: envelope.ocs.meta.statuscode, + message: envelope + .ocs + .meta + .message + .unwrap_or_else(|| envelope.ocs.meta.status), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ocs_response_deserialization() { + let sample_ocs_json = r#"{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "id": "42", + "share_type": 3, + "uid_owner": "alice", + "url": "https://cloud.example.com/s/AbCdEf12345", + "token": "AbCdEf12345", + "path": "/Uploads/photo.jpg", + "permissions": 1, + "expiration": null + } + } + }"#; + + let envelope: OcsEnvelope = serde_json::from_str(sample_ocs_json).unwrap(); + assert_eq!(envelope.ocs.meta.statuscode, 200); + + let data = envelope.ocs.data.unwrap(); + assert_eq!(data.id, "42"); + assert_eq!(data.token, "AbCdEf12345"); + assert_eq!(data.url, "https://cloud.example.com/s/AbCdEf12345"); + assert_eq!(data.share_type, 3); + assert_eq!(data.path, "/Uploads/photo.jpg"); + } + + #[test] + fn test_create_share_params_builder() { + let params = CreateShareParams::public_link("Documents/report.pdf") + .with_password("supersecret") + .with_expire_date("2026-12-31"); + + assert_eq!(params.path, "Documents/report.pdf"); + assert_eq!(params.share_type, ShareType::PublicLink); + assert_eq!(params.password.as_deref(), Some("supersecret")); + assert_eq!(params.expire_date.as_deref(), Some("2026-12-31")); + } +}