brume/concrete/nextcloud/
mod.rs

1//! Manipulation of a Nextcloud filesystem with WebDAV
2
3use std::{
4    error::Error,
5    fmt::{Display, Formatter},
6    io::{self},
7    string::FromUtf8Error,
8    sync::Arc,
9};
10
11use bytes::Bytes;
12use futures::{Stream, TryStream, TryStreamExt};
13use reqwest::Body;
14use reqwest_dav::{Auth, Client, ClientBuilder, Depth};
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18mod dav;
19
20use crate::{
21    update::{IsModified, ModificationState},
22    vfs::{Vfs, VirtualPath, VirtualPathError},
23};
24
25use dav::{TagError, dav_parse_entity_tag, dav_parse_vfs};
26
27use super::{FSBackend, FsBackendError, FsInstanceDescription, Named};
28
29const NC_DAV_PATH_STR: &str = "/remote.php/dav/files/";
30
31/// An error during synchronisation with the nextcloud file system
32#[derive(Error, Debug)]
33pub enum NextcloudFsError {
34    #[error("a path provided by the server is invalid")]
35    InvalidPath(#[from] VirtualPathError),
36    #[error("a tag provided by the server is invalid")]
37    InvalidTag(#[from] TagError),
38    #[error("the structure of the nextcloud FS is not valid")]
39    BadStructure,
40    #[error("failed to decode server provided url")]
41    UrlDecode(#[from] FromUtf8Error),
42    #[error("a dav protocol error occurred during communication with the nextcloud server")]
43    ProtocolError(#[from] reqwest_dav::Error),
44    #[error("io error while sending or receiving a file")]
45    IoError(#[from] io::Error),
46}
47
48impl NextcloudFsError {
49    /// Return the inner error message in case of protocol error
50    pub fn protocol_error_message(&self) -> Option<String> {
51        match self {
52            NextcloudFsError::ProtocolError(reqwest_dav::Error::Reqwest(error)) => {
53                if let Some(source) = error.source() {
54                    if let Some(source2) = source.source() {
55                        Some(source2.to_string())
56                    } else {
57                        Some(source.to_string())
58                    }
59                } else {
60                    Some(error.to_string())
61                }
62            }
63            _ => None,
64        }
65    }
66}
67
68impl From<reqwest::Error> for NextcloudFsError {
69    fn from(value: reqwest::Error) -> Self {
70        Self::ProtocolError(value.into())
71    }
72}
73
74impl From<NextcloudFsError> for FsBackendError {
75    fn from(value: NextcloudFsError) -> Self {
76        Self(Arc::new(value))
77    }
78}
79
80/// The nextcloud FileSystem, accessed with the dav protocol
81#[derive(Debug)]
82pub struct NextcloudFs {
83    client: Client,
84    name: String,
85}
86
87impl NextcloudFs {
88    // TODO: handle folders that are not the user root folder
89    pub fn new(url: &str, login: &str, password: &str) -> Result<Self, NextcloudFsError> {
90        let name = login.to_string();
91        let client = ClientBuilder::new()
92            .set_host(format!("{}{}{}/", url, NC_DAV_PATH_STR, &name))
93            .set_auth(Auth::Basic(login.to_string(), password.to_string()))
94            .build()?;
95
96        Ok(Self {
97            client,
98            name: name.to_string(),
99        })
100    }
101}
102
103impl FSBackend for NextcloudFs {
104    type SyncInfo = NextcloudSyncInfo;
105
106    type IoError = NextcloudFsError;
107
108    type CreationInfo = NextcloudFsCreationInfo;
109
110    type Description = NextcloudFsDescription;
111
112    async fn validate(info: &Self::CreationInfo) -> Result<(), Self::IoError> {
113        // Try to create a nextcloud client instance and access the remote url
114        let nextcloud: Self = info.clone().try_into()?;
115        nextcloud
116            .client
117            .list("", Depth::Number(0))
118            .await
119            .map(|_| ())
120            .map_err(|e| e.into())
121    }
122
123    fn description(&self) -> Self::Description {
124        NextcloudFsDescription {
125            server_url: self
126                .client
127                .host
128                .trim_end_matches('/')
129                .trim_end_matches(&self.name)
130                .trim_end_matches(&NC_DAV_PATH_STR)
131                .to_string(),
132            name: self.name.clone(),
133        }
134    }
135
136    async fn get_sync_info(&self, path: &VirtualPath) -> Result<Self::SyncInfo, Self::IoError> {
137        let elements = self.client.list(path.into(), Depth::Number(0)).await?;
138
139        let elem = elements.first().ok_or(NextcloudFsError::BadStructure)?;
140
141        dav_parse_entity_tag(elem.clone())
142    }
143
144    async fn load_virtual(&self) -> Result<Vfs<Self::SyncInfo>, Self::IoError> {
145        let elements = self.client.list("", Depth::Infinity).await?;
146
147        let vfs_root = dav_parse_vfs(elements, &self.name)?;
148
149        Ok(Vfs::new(vfs_root))
150    }
151
152    async fn read_file(
153        &self,
154        path: &VirtualPath,
155    ) -> Result<impl Stream<Item = Result<Bytes, Self::IoError>> + 'static, Self::IoError> {
156        Ok(self
157            .client
158            .get(path.into())
159            .await?
160            .bytes_stream()
161            .map_err(|e| e.into()))
162    }
163
164    async fn write_file<Data: TryStream + Send + 'static>(
165        &self,
166        path: &VirtualPath,
167        data: Data,
168    ) -> Result<Self::SyncInfo, Self::IoError>
169    where
170        Data::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
171        Bytes: From<Data::Ok>,
172    {
173        let body = Body::wrap_stream(data);
174
175        self.client.put(path.into(), body).await?;
176
177        // Extract the tag of the created file
178        let mut entities = self.client.list(path.into(), Depth::Number(0)).await?;
179        entities
180            .pop()
181            .ok_or(NextcloudFsError::BadStructure)
182            .and_then(dav_parse_entity_tag)
183    }
184
185    async fn rm(&self, path: &VirtualPath) -> Result<(), Self::IoError> {
186        self.client.delete(path.into()).await.map_err(|e| e.into())
187    }
188
189    async fn mkdir(&self, path: &VirtualPath) -> Result<Self::SyncInfo, Self::IoError> {
190        self.client.mkcol(path.into()).await?;
191
192        // Extract the tag of the created dir
193        let mut entities = self.client.list(path.into(), Depth::Number(0)).await?;
194        entities
195            .pop()
196            .ok_or(NextcloudFsError::BadStructure)
197            .and_then(dav_parse_entity_tag)
198    }
199
200    async fn rmdir(&self, path: &VirtualPath) -> Result<(), Self::IoError> {
201        self.client.delete(path.into()).await.map_err(|e| e.into())
202    }
203}
204
205/// Metadata used to detect modifications of a nextcloud FS node
206///
207/// The nodes are compared using the nextcloud [etag] field, which is modified by the
208/// server if a node or its content is modified.
209///
210/// [etag]: https://docs.nextcloud.com/desktop/3.13/architecture.html#synchronization-by-time-versus-etag
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct NextcloudSyncInfo {
213    tag: u128,
214}
215
216impl NextcloudSyncInfo {
217    pub fn new(tag: u128) -> Self {
218        Self { tag }
219    }
220}
221
222impl Named for NextcloudSyncInfo {
223    const TYPE_NAME: &'static str = "Nextcloud";
224}
225
226impl IsModified for NextcloudSyncInfo {
227    fn modification_state(&self, reference: &Self) -> ModificationState {
228        if self.tag != reference.tag {
229            ModificationState::Modified
230        } else {
231            ModificationState::RecursiveUnmodified
232        }
233    }
234}
235
236impl<'a> From<&'a NextcloudSyncInfo> for NextcloudSyncInfo {
237    fn from(value: &'a NextcloudSyncInfo) -> Self {
238        value.to_owned()
239    }
240}
241
242impl<'a> From<&'a NextcloudSyncInfo> for () {
243    fn from(_value: &'a NextcloudSyncInfo) -> Self {}
244}
245
246/// Description of a connection to a nextcloud instance
247#[derive(Clone, Hash, PartialEq, Eq, Debug, Serialize, Deserialize)]
248pub struct NextcloudFsDescription {
249    server_url: String,
250    name: String,
251}
252
253impl FsInstanceDescription for NextcloudFsDescription {
254    fn name(&self) -> &str {
255        &self.name
256    }
257}
258
259impl Display for NextcloudFsDescription {
260    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
261        write!(f, "url: {}, folder: {}", self.server_url, self.name)
262    }
263}
264
265/// Info needed to create a new connection to a nextcloud server
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct NextcloudFsCreationInfo {
268    server_url: String,
269    login: String,
270    password: String,
271}
272
273impl NextcloudFsCreationInfo {
274    pub fn new(server_url: &str, login: &str, password: &str) -> Self {
275        Self {
276            server_url: server_url.to_string(),
277            login: login.to_string(),
278            password: password.to_string(),
279        }
280    }
281}
282
283impl From<NextcloudFsCreationInfo> for NextcloudFsDescription {
284    fn from(value: NextcloudFsCreationInfo) -> Self {
285        Self {
286            server_url: value.server_url,
287            name: value.login,
288        }
289    }
290}
291
292impl TryFrom<NextcloudFsCreationInfo> for NextcloudFs {
293    type Error = <NextcloudFs as FSBackend>::IoError;
294
295    fn try_from(value: NextcloudFsCreationInfo) -> Result<Self, Self::Error> {
296        Self::new(&value.server_url, &value.login, &value.password)
297    }
298}