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        let user = validate_user(user_id)?;
54        let path = ApiPath::dav(format!("versions/{user}/versions/{file_id}"))?;
55
56        let body = PropfindBody::new([
57            DavProperty::LastModified,
58            DavProperty::ContentLength,
59            DavProperty::Etag,
60            DavProperty::ResourceType,
61        ]);
62
63        let mut headers = self.authenticated_headers();
64        headers.set(DEPTH_HEADER, Depth::One.as_header_value());
65        headers.set("Content-Type", XML_CONTENT_TYPE);
66
67        let request = Request::new(HttpMethod::Propfind, path.clone())
68            .with_headers(headers)
69            .body_bytes(body.to_xml().into_bytes());
70
71        let response = self.transport.execute(request).await?;
72
73        if response.status() != StatusCode::new(207) {
74            return Err(Error::Status(StatusError::new(
75                HttpMethod::Propfind.as_str(),
76                path,
77                response.status(),
78                response.body(),
79            )));
80        }
81
82        let body_str = std::str::from_utf8(response.body())
83            .map_err(|e| Error::Decode(DecodeError::new("FileVersion", e)))?;
84
85        decode_multistatus(body_str, file_id).map_err(Error::Decode)
86    }
87
88    /// Downloads the content of a specific revision.
89    ///
90    /// Sends a `GET` request to `/remote.php/dav/versions/{user_id}/versions/{file_id}/{revision_id}`.
91    pub async fn get(
92        &self,
93        user_id: &str,
94        file_id: u64,
95        revision_id: &str,
96    ) -> Result<Vec<u8>, Error> {
97        let user = validate_user(user_id)?;
98        let revision = validate_revision(revision_id)?;
99        let path = ApiPath::dav(format!("versions/{user}/versions/{file_id}/{revision}"))?;
100
101        let headers = self.authenticated_headers();
102        let request = Request::new(HttpMethod::Get, path.clone()).with_headers(headers);
103
104        let response = self.transport.execute(request).await?;
105
106        if !response.status().is_success() {
107            return Err(Error::Status(StatusError::new(
108                HttpMethod::Get.as_str(),
109                path,
110                response.status(),
111                response.body(),
112            )));
113        }
114
115        Ok(response.into_body())
116    }
117
118    /// Restores a past revision to be the current file content.
119    ///
120    /// Sends a `MOVE` request from `/remote.php/dav/versions/{user_id}/versions/{file_id}/{revision_id}`
121    /// to `/remote.php/dav/versions/{user_id}/restore/{file_id}`.
122    pub async fn restore(
123        &self,
124        user_id: &str,
125        file_id: u64,
126        revision_id: &str,
127    ) -> Result<(), Error> {
128        let user = validate_user(user_id)?;
129        let revision = validate_revision(revision_id)?;
130
131        let source = ApiPath::dav(format!("versions/{user}/versions/{file_id}/{revision}"))?;
132        let destination = ApiPath::dav(format!("versions/{user}/restore/{file_id}"))?;
133
134        let mut headers = self.authenticated_headers();
135        headers.set(DESTINATION_HEADER, destination.as_str());
136
137        let request = Request::new(HttpMethod::Move, source.clone()).with_headers(headers);
138        let response = self.transport.execute(request).await?;
139
140        if !response.status().is_success() {
141            return Err(Error::Status(StatusError::new(
142                HttpMethod::Move.as_str(),
143                source,
144                response.status(),
145                response.body(),
146            )));
147        }
148
149        Ok(())
150    }
151
152    fn authenticated_headers(&self) -> Headers {
153        let mut headers = Headers::new();
154        self.auth.authenticate(&mut headers);
155        headers
156    }
157}