Skip to main content

nextcloud_versions/
client.rs

1//! Asynchronous client for Nextcloud WebDAV file versions.
2
3use nextcloud_dav::{DEPTH_HEADER, DESTINATION_HEADER, DavProperty, Depth, PropfindBody};
4use nextcloud_sdk_core::{
5    ApiPath, AuthProvider, DecodeError, Error, Headers, HttpMethod, Request, StatusCode,
6    StatusError, Transport,
7};
8
9use crate::decode::decode_multistatus;
10use crate::validation::{validate_revision, validate_user};
11use crate::version::FileVersion;
12
13#[cfg(test)]
14mod tests;
15
16/// Content type for WebDAV PROPFIND requests.
17const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
18
19/// Client for Nextcloud's WebDAV file versions endpoint.
20///
21/// Interacts with `/remote.php/dav/versions/{user}/...` to list, download, and restore
22/// previous versions of a file.
23///
24/// Generic over the [`Transport`] and [`AuthProvider`], both held by reference so that
25/// connection pools and credentials can be shared across tasks.
26#[derive(Clone, Copy, Debug)]
27pub struct VersionsClient<'a, T: Transport, A: AuthProvider> {
28    transport: &'a T,
29    auth: &'a A,
30}
31
32impl<'a, T: Transport, A: AuthProvider> VersionsClient<'a, T, A> {
33    /// Binds a versions client to a transport and credential provider.
34    pub const fn new(transport: &'a T, auth: &'a A) -> Self {
35        Self { transport, auth }
36    }
37
38    /// The transport this client sends requests through.
39    pub const fn transport(&self) -> &'a T {
40        self.transport
41    }
42
43    /// The credential provider applied to every call.
44    pub const fn auth(&self) -> &'a A {
45        self.auth
46    }
47
48    /// Lists all available revisions of a file.
49    ///
50    /// Sends a `PROPFIND` request to `/remote.php/dav/versions/{user_id}/versions/{file_id}`
51    /// requesting revision metadata (`getlastmodified`, `getcontentlength`, and `getetag`).
52    pub async fn list(&self, user_id: &str, file_id: u64) -> Result<Vec<FileVersion>, Error> {
53        tracing::debug!(user_id, file_id, "Listing file versions");
54        let user = validate_user(user_id)?;
55        let path = ApiPath::dav(format!("versions/{user}/versions/{file_id}"))?;
56
57        let body = PropfindBody::new([
58            DavProperty::LastModified,
59            DavProperty::ContentLength,
60            DavProperty::Etag,
61            DavProperty::ResourceType,
62        ]);
63
64        let mut headers = self.authenticated_headers();
65        headers.set(DEPTH_HEADER, Depth::One.as_header_value());
66        headers.set("Content-Type", XML_CONTENT_TYPE);
67
68        let request = Request::new(HttpMethod::Propfind, path.clone())
69            .with_headers(headers)
70            .body_bytes(body.to_xml().into_bytes());
71
72        let response = self.transport.execute(request).await?;
73
74        if response.status() != StatusCode::new(207) {
75            return Err(Error::Status(StatusError::new(
76                HttpMethod::Propfind.as_str(),
77                path,
78                response.status(),
79                response.body(),
80            )));
81        }
82
83        let body_str = std::str::from_utf8(response.body())
84            .map_err(|e| Error::Decode(DecodeError::new("FileVersion", e)))?;
85
86        decode_multistatus(body_str, file_id).map_err(Error::Decode)
87    }
88
89    /// Downloads the content of a specific revision.
90    ///
91    /// Sends a `GET` request to `/remote.php/dav/versions/{user_id}/versions/{file_id}/{revision_id}`.
92    pub async fn get(
93        &self,
94        user_id: &str,
95        file_id: u64,
96        revision_id: &str,
97    ) -> Result<Vec<u8>, Error> {
98        tracing::debug!(
99            user_id,
100            file_id,
101            revision_id,
102            "Downloading file version content"
103        );
104        let user = validate_user(user_id)?;
105        let revision = validate_revision(revision_id)?;
106        let path = ApiPath::dav(format!("versions/{user}/versions/{file_id}/{revision}"))?;
107
108        let headers = self.authenticated_headers();
109        let request = Request::new(HttpMethod::Get, path.clone()).with_headers(headers);
110
111        let response = self.transport.execute(request).await?;
112
113        if !response.status().is_success() {
114            tracing::warn!(
115                user_id,
116                file_id,
117                revision_id,
118                status = %response.status(),
119                "Failed to download file version"
120            );
121            return Err(Error::Status(StatusError::new(
122                HttpMethod::Get.as_str(),
123                path,
124                response.status(),
125                response.body(),
126            )));
127        }
128
129        Ok(response.into_body())
130    }
131
132    /// Restores a past revision to be the current file content.
133    ///
134    /// Sends a `MOVE` request from `/remote.php/dav/versions/{user_id}/versions/{file_id}/{revision_id}`
135    /// to `/remote.php/dav/versions/{user_id}/restore/{file_id}`.
136    pub async fn restore(
137        &self,
138        user_id: &str,
139        file_id: u64,
140        revision_id: &str,
141    ) -> Result<(), Error> {
142        tracing::info!(user_id, file_id, revision_id, "Restoring file version");
143        let user = validate_user(user_id)?;
144        let revision = validate_revision(revision_id)?;
145
146        let source = ApiPath::dav(format!("versions/{user}/versions/{file_id}/{revision}"))?;
147        let destination = ApiPath::dav(format!("versions/{user}/restore/{file_id}"))?;
148
149        let mut headers = self.authenticated_headers();
150        headers.set(DESTINATION_HEADER, destination.as_str());
151
152        let request = Request::new(HttpMethod::Move, source.clone()).with_headers(headers);
153        let response = self.transport.execute(request).await?;
154
155        if !response.status().is_success() {
156            return Err(Error::Status(StatusError::new(
157                HttpMethod::Move.as_str(),
158                source,
159                response.status(),
160                response.body(),
161            )));
162        }
163
164        Ok(())
165    }
166
167    fn authenticated_headers(&self) -> Headers {
168        let mut headers = Headers::new();
169        self.auth.authenticate(&mut headers);
170        headers
171    }
172}