Skip to main content

nextcloud_uploads/
client.rs

1//! Asynchronous client for Nextcloud's WebDAV Chunked Upload v2 API.
2
3use std::fmt;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7pub use nextcloud_dav::DESTINATION_HEADER;
8use nextcloud_sdk_core::{
9    ApiPath, AuthProvider, Error, Headers, HttpMethod, Request, Response, StatusError, Transport,
10};
11
12use crate::path::{
13    abort_path, assembly_path, chunk_path, destination_header_value, upload_collection_path,
14};
15use crate::session::UploadSession;
16
17#[cfg(test)]
18mod tests;
19
20/// Header conveying total file size during chunked upload and assembly.
21pub const TOTAL_LENGTH_HEADER: &str = "OC-Total-Length";
22
23/// Header conveying target modification time (in unix seconds) during chunk assembly.
24pub const MTIME_HEADER: &str = "X-OC-Mtime";
25
26static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
27
28/// Generates a unique upload session token from system timestamp and an atomic counter.
29fn generate_upload_id() -> String {
30    let now = SystemTime::now()
31        .duration_since(UNIX_EPOCH)
32        .unwrap_or_default()
33        .as_millis();
34    let count = UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed);
35    format!("chunked-upload-{now}-{count}")
36}
37
38/// Client for Nextcloud's WebDAV Chunked Upload v2 protocol.
39///
40/// Manages creating upload directories, streaming individual chunks, assembling the
41/// uploaded chunks into the destination file, or aborting the upload.
42///
43/// Generic over the [`Transport`] and [`AuthProvider`], held by reference so that
44/// connection pools and credentials can be shared across tasks.
45#[derive(Clone, Copy, Debug)]
46pub struct UploadClient<'a, T: Transport, A: AuthProvider> {
47    transport: &'a T,
48    auth: &'a A,
49}
50
51impl<'a, T: Transport, A: AuthProvider> UploadClient<'a, T, A> {
52    /// Binds an upload client to a transport and credential provider.
53    pub const fn new(transport: &'a T, auth: &'a A) -> Self {
54        Self { transport, auth }
55    }
56
57    /// The transport this client sends requests through.
58    pub const fn transport(&self) -> &'a T {
59        self.transport
60    }
61
62    /// The credential provider applied to every request.
63    pub const fn auth(&self) -> &'a A {
64        self.auth
65    }
66
67    /// Initializes a new chunked upload session by sending an `MKCOL` request with an auto-generated upload ID.
68    ///
69    /// Sends `MKCOL /remote.php/dav/uploads/{user_id}/{upload_id}` with the `Destination` header set.
70    pub async fn start(
71        &self,
72        user_id: &str,
73        destination_path: &str,
74        total_size: u64,
75    ) -> Result<UploadSession, Error> {
76        let upload_id = generate_upload_id();
77        self.start_with_id(user_id, destination_path, total_size, &upload_id)
78            .await
79    }
80
81    /// Initializes a new chunked upload session using an explicit `upload_id`.
82    ///
83    /// Sends `MKCOL /remote.php/dav/uploads/{user_id}/{upload_id}` with the `Destination` header set.
84    pub async fn start_with_id(
85        &self,
86        user_id: &str,
87        destination_path: &str,
88        total_size: u64,
89        upload_id: &str,
90    ) -> Result<UploadSession, Error> {
91        tracing::info!(
92            user_id,
93            destination_path,
94            total_size,
95            upload_id,
96            "Starting chunked upload session"
97        );
98        let destination = destination_header_value(user_id, destination_path)?;
99        let path = upload_collection_path(user_id, upload_id)?;
100
101        let mut headers = self.authenticated_headers();
102        headers.set(DESTINATION_HEADER, &destination);
103
104        let request = Request::new(HttpMethod::Mkcol, path.clone()).with_headers(headers);
105        let response = self.transport.execute(request).await?;
106
107        ensure_success(HttpMethod::Mkcol, &path, &response)?;
108
109        Ok(UploadSession::new(
110            user_id,
111            upload_id,
112            destination,
113            total_size,
114        ))
115    }
116
117    /// Uploads an individual chunk into the upload collection.
118    ///
119    /// Sends `PUT /remote.php/dav/uploads/{user_id}/{upload_id}/{chunk_number}` with:
120    /// - `Destination: {destination_path}`
121    /// - `OC-Total-Length: {total_size}`
122    /// - payload containing `chunk_data`
123    ///
124    /// Automatically marks the chunk index as uploaded in the session upon success.
125    pub async fn upload_chunk(
126        &self,
127        session: &UploadSession,
128        chunk_index: u32,
129        chunk_data: Vec<u8>,
130    ) -> Result<(), Error> {
131        let path = chunk_path(session.user_id(), session.upload_id(), chunk_index)?;
132        tracing::debug!(
133            upload_id = session.upload_id(),
134            chunk_index,
135            bytes = chunk_data.len(),
136            "Uploading file chunk"
137        );
138
139        let mut headers = self.authenticated_headers();
140        headers.set(DESTINATION_HEADER, session.destination_path());
141        headers.set(TOTAL_LENGTH_HEADER, session.total_size().to_string());
142
143        let request = Request::new(HttpMethod::Put, path.clone())
144            .with_headers(headers)
145            .body_bytes(chunk_data);
146
147        let response = self.transport.execute(request).await?;
148        ensure_success(HttpMethod::Put, &path, &response)?;
149
150        session.mark_chunk_uploaded(chunk_index);
151        Ok(())
152    }
153
154    /// Finalizes the upload by moving `.file` to the destination path.
155    ///
156    /// Sends `MOVE /remote.php/dav/uploads/{user_id}/{upload_id}/.file` with:
157    /// - `Destination: {destination_path}`
158    /// - `OC-Total-Length: {total_size}`
159    /// - optionally `X-OC-Mtime: {mtime}`
160    pub async fn finish(&self, session: &UploadSession, mtime: Option<u64>) -> Result<(), Error> {
161        let path = assembly_path(session.user_id(), session.upload_id())?;
162        tracing::info!(
163            upload_id = session.upload_id(),
164            destination = session.destination_path(),
165            "Finalizing chunked upload assembly"
166        );
167
168        let mut headers = self.authenticated_headers();
169        headers.set(DESTINATION_HEADER, session.destination_path());
170        headers.set(TOTAL_LENGTH_HEADER, session.total_size().to_string());
171        if let Some(mtime) = mtime {
172            headers.set(MTIME_HEADER, mtime.to_string());
173        }
174
175        let request = Request::new(HttpMethod::Move, path.clone()).with_headers(headers);
176        let response = self.transport.execute(request).await?;
177
178        ensure_success(HttpMethod::Move, &path, &response)
179    }
180
181    /// Cancels and removes the temporary upload collection.
182    ///
183    /// Sends `DELETE /remote.php/dav/uploads/{user_id}/{upload_id}/`.
184    pub async fn abort(&self, session: &UploadSession) -> Result<(), Error> {
185        let path = abort_path(session.user_id(), session.upload_id())?;
186        tracing::warn!(
187            upload_id = session.upload_id(),
188            "Aborting chunked upload session"
189        );
190
191        let headers = self.authenticated_headers();
192        let request = Request::new(HttpMethod::Delete, path.clone()).with_headers(headers);
193        let response = self.transport.execute(request).await?;
194
195        ensure_success(HttpMethod::Delete, &path, &response)
196    }
197
198    fn authenticated_headers(&self) -> Headers {
199        let mut headers = Headers::new();
200        self.auth.authenticate(&mut headers);
201        headers
202    }
203}
204
205fn ensure_success(method: HttpMethod, path: &ApiPath, response: &Response) -> Result<(), Error> {
206    if !response.status().is_success() {
207        tracing::error!(
208            method = ?method,
209            path = %path.as_str(),
210            status = %response.status(),
211            "Chunked upload operation failed"
212        );
213        return Err(Error::Status(StatusError::new(
214            method.as_str(),
215            path.clone(),
216            response.status(),
217            response.body(),
218        )));
219    }
220    tracing::trace!(method = ?method, path = %path.as_str(), "Chunked upload request succeeded");
221    Ok(())
222}
223
224impl<T: Transport, A: AuthProvider> fmt::Display for UploadClient<'_, T, A> {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        f.write_str("UploadClient")
227    }
228}