Implements share link generation.
This commit is contained in:
14
Tasks.md
14
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-001](#nut-001) | Establish Repository Structure | Fixed | Foundation |
|
||||||
| [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 | 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-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature |
|
||||||
| [NUT-006](#nut-006) | Implement Credential Storage System | Triage | Feature |
|
| [NUT-006](#nut-006) | Implement Credential Storage System | Triage | Feature |
|
||||||
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | 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
|
- NUT-002
|
||||||
|
|
||||||
|
|
||||||
<a id="nut-004" class="task" data-status="triage" data-task-type="feature"></a>
|
<a id="nut-004" class="task" data-status="done" data-task-type="feature"></a>
|
||||||
### Implement OCS Share Link Generation
|
### Implement OCS Share Link Generation
|
||||||
**ID:** NUT-004
|
**ID:** NUT-004
|
||||||
**Status:** Triage
|
**Status:** Fixed
|
||||||
**Type:** Feature
|
**Type:** Feature
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Implement creation of public share links using the OCS Sharing API.
|
Implement creation of public share links using the OCS Sharing API.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- [ ] POST to `/ocs/v2.php/apps/files_sharing/api/v1/shares`
|
- [x] POST to `/ocs/v2.php/apps/files_sharing/api/v1/shares`
|
||||||
- [ ] Parse JSON/XML response
|
- [x] Parse JSON/XML response
|
||||||
- [ ] Extract share token
|
- [x] Extract share token
|
||||||
- [ ] Return share metadata
|
- [x] Return share metadata
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
- NUT-002
|
- NUT-002
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::config::ClientConfig;
|
use crate::config::ClientConfig;
|
||||||
use crate::error::{NextcloudError, Result};
|
use crate::error::{NextcloudError, Result};
|
||||||
|
use crate::models::{UploadOptions, UploadResult};
|
||||||
|
use crate::progress::ProgressCallback;
|
||||||
|
|
||||||
/// Response payload from Nextcloud's `/status.php` endpoint.
|
/// Response payload from Nextcloud's `/status.php` endpoint.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -133,6 +136,35 @@ impl NextcloudClient {
|
|||||||
let status = response.json::<ServerStatus>().await?;
|
let status = response.json::<ServerStatus>().await?;
|
||||||
Ok(status)
|
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<P: AsRef<Path>>(
|
||||||
|
&self,
|
||||||
|
local_path: P,
|
||||||
|
options: &UploadOptions,
|
||||||
|
progress: Option<ProgressCallback>,
|
||||||
|
) -> Result<UploadResult> {
|
||||||
|
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.
|
/// Simple Base64 encoder helper avoiding extra external crate dependencies.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod config;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
|
pub mod sharing;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
// Convenient top-level re-exports
|
// Convenient top-level re-exports
|
||||||
@@ -16,3 +17,4 @@ pub use config::{AccountCredentials, ClientConfig};
|
|||||||
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};
|
||||||
|
pub use sharing::{CreateShareParams, ShareInfo, ShareType};
|
||||||
|
|||||||
278
nextcloud_client/src/sharing.rs
Normal file
278
nextcloud_client/src/sharing.rs
Normal file
@@ -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<String>,
|
||||||
|
|
||||||
|
/// Bitmask permissions (e.g. 1 = Read, 31 = All).
|
||||||
|
pub permissions: Option<u32>,
|
||||||
|
|
||||||
|
/// Optional expiration date in `YYYY-MM-DD` format.
|
||||||
|
pub expire_date: Option<String>,
|
||||||
|
|
||||||
|
/// Optional note for the recipient.
|
||||||
|
pub note: Option<String>,
|
||||||
|
|
||||||
|
/// Optional custom label for public links.
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateShareParams {
|
||||||
|
/// Create parameters for a public link share.
|
||||||
|
pub fn public_link(path: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal OCS envelope format
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OcsEnvelope<T> {
|
||||||
|
ocs: OcsPayload<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OcsPayload<T> {
|
||||||
|
meta: OcsMeta,
|
||||||
|
data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OcsMeta {
|
||||||
|
#[serde(default)]
|
||||||
|
status: String,
|
||||||
|
statuscode: i32,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ShareInfo> {
|
||||||
|
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<ShareInfo> = 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<ShareInfo> {
|
||||||
|
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<serde_json::Value> = 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<ShareInfo> = 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user