nextcloud_client_api/
lib.rs

1#![allow(
2    clippy::blanket_clippy_restriction_lints,
3    reason = "I can't allow this in the cargo.toml for some reason, so I add it here."
4)]
5//! This crate provides an API to the `NextCloud` linux client.
6//! It connects to the socket of the client to interact.
7//! The socket is located in the runtime directory, which is defined by the
8//! `$XDG_RUNTIME_DIR` environment variable.
9//! If this variable is not available, the creation will fail.
10//!
11//! The API is based on the source code published here: <https://github.com/nextcloud/desktop/blob/master/src/gui/socketapi/socketapi.h>
12//!
13//! # Examples
14//! ```no_run
15//! use std::path::Path;
16//! use nextcloud_client_api::Api;
17//!
18//! let mut api = Api::new()
19//!     .expect("failed to open API");
20//! let version = api.version()
21//!     .expect("failed to fetch version");
22//! let file_status = api.retrieve_file_status(
23//!         &Path::new("/Nextcloud/Documents/About-Storage-Share.pdf")
24//!     ).expect("failed to retrieve file status");
25//! ```
26use core::{result::Result as StdResult, time::Duration};
27use std::path::{Path, PathBuf};
28
29use log::trace;
30use thiserror::Error as ThisError;
31
32use crate::{
33    api_verb::ApiVerb,
34    file_status::{Error as FileStatusError, FileStatus},
35    menu_item::{Error as MenuItemError, MenuItem},
36    message::Message,
37    share_status::{Error as ShareStatusError, ShareStatus},
38    socket::{Error as NextCloudClientSocketError, NextCloudClientSocket},
39};
40
41mod api_verb;
42mod file_status;
43mod menu_item;
44mod message;
45mod share_status;
46mod socket;
47
48/// Result of API interactions
49pub type Result<T> = StdResult<T, Error>;
50
51/// Extensive error codes for drill down
52#[derive(Debug, ThisError)]
53#[non_exhaustive]
54pub enum Error {
55    /// Failed to open the socket to `NextCloud` client.
56    #[error("failed to open socket: {0}")]
57    SocketFail(#[from] NextCloudClientSocketError),
58
59    /// The path to operate with is not registered by the `NextCloud` client
60    /// and therefore not shared or synced.
61    #[error("requested path/file is not registered in NextCloud: {0}")]
62    NotInRegister(PathBuf),
63
64    /// The API did not respond to a request, but was expected to do so.
65    #[error("no response")]
66    NoResponse,
67
68    /// Unknown file status has been responded.
69    #[error("failed to parse file status: {0}")]
70    ParsingFailedFileStatus(#[from] FileStatusError),
71
72    /// Unknown share status has been responded.
73    #[error("failed to parse share status: {0}")]
74    ParsingFailedShareStatus(#[from] ShareStatusError),
75
76    /// Unknown menu item has been responded.
77    #[error("failed to parse menu item: {0}")]
78    ParsingFailedMenuItem(#[from] MenuItemError),
79
80    /// File can't be shared.
81    #[error("file can't be shared due to missing options or the file is not in the correct path")]
82    CannotShareFile,
83
84    /// It is not allowed to share the root directory of your `NextCloud` account.
85    #[error("you can't share the root of your account")]
86    CannotShareRoot,
87
88    /// The client is currently offline.
89    #[error("the client is not connected to a backend")]
90    NotConnected,
91
92    /// The requested file is not synced yet, and therefore can't be shared with others.
93    #[error("the file is not synced yet")]
94    NotSynced,
95}
96
97/// interface object to interact with `NextCloud` client
98pub struct Api {
99    socket: NextCloudClientSocket,
100    registered_paths: Vec<PathBuf>,
101}
102
103impl Api {
104    /// `new` opens the socket to the `NextCloud` client.
105    /// After the connect, the initial messages are received
106    /// and processed. Those contain the registered directories
107    /// in the `NextCloud` client.
108    ///
109    /// # Errors
110    /// Will return errors if the socket can't be opened or there is a
111    /// parsing issue with the initial messages.
112    #[inline]
113    pub fn new() -> Result<Self> {
114        Api::build(NextCloudClientSocket::new()?)
115    }
116
117    #[cfg(test)]
118    pub(crate) fn new_test(socket_file: &Path) -> Result<Self> {
119        Api::build(NextCloudClientSocket::new_test(socket_file)?)
120    }
121
122    #[cfg_attr(
123        not(test),
124        expect(clippy::single_call_fn, reason = "abstraction for test mocks")
125    )]
126    fn build(socket: NextCloudClientSocket) -> Result<Self> {
127        let mut result = Self {
128            socket,
129            registered_paths: Vec::new(),
130        };
131
132        let initial_messages = result.read_filtered_responses()?;
133        for msg in initial_messages {
134            trace!("initial: {msg}");
135        }
136
137        Ok(result)
138    }
139
140    fn read_filtered_responses(&mut self) -> Result<Vec<Message>> {
141        let responses = self.socket.read_until_settled(Duration::from_millis(200))?;
142
143        let (registered_paths, other): (Vec<Message>, Vec<Message>) = responses
144            .into_iter()
145            .inspect(|response| {
146                trace!("received response: {response}");
147            })
148            .partition(|response| matches!(response.verb, ApiVerb::RegisterPath));
149
150        self.registered_paths.extend(
151            registered_paths
152                .into_iter()
153                .map(|path_response| PathBuf::from(path_response.msg)),
154        );
155
156        Ok(other)
157    }
158
159    /// `version` fetches the current version of the installed nextcloud-client.
160    /// The returned value is as reported and not parsed in any format.
161    ///
162    /// # Errors
163    /// Returns error if the request can't be transmitted to the client
164    /// or if an unknown response is received.
165    #[inline]
166    pub fn version(&mut self) -> Result<String> {
167        self.socket.write_message(&Message {
168            verb: ApiVerb::Version,
169            msg: String::default(),
170        })?;
171
172        let version = loop {
173            let responses = self.read_filtered_responses()?;
174            match responses.into_iter().find_map(|response| {
175                (matches!(response.verb, ApiVerb::Version)).then_some(response.msg)
176            }) {
177                None => {}
178                Some(version) => break version,
179            }
180        };
181
182        trace!("version response: {version}");
183        Ok(version)
184    }
185
186    /// `get_strings` reads the translation strings for context menu
187    /// actions.
188    /// The result is a vector of strings.
189    /// The format is `<ACTION>:<Menu String>`.
190    ///
191    /// # Errors
192    /// Returns error if the request can't be transmitted to the client
193    /// or if an unknown response is received.
194    #[inline]
195    pub fn get_strings(&mut self) -> Result<Vec<String>> {
196        self.socket.write_message(&Message {
197            verb: ApiVerb::GetStrings,
198            msg: String::default(),
199        })?;
200
201        Ok(self
202            .read_filtered_responses()?
203            .into_iter()
204            .filter_map(|response| {
205                trace!("string response: {}", response.msg);
206                if matches!(response.msg.as_str(), "BEGIN" | "END") {
207                    None
208                } else {
209                    Some(response.msg)
210                }
211            })
212            .collect::<Vec<_>>())
213    }
214
215    /// `get_menu_items` reads the available actions for the file or folder.
216    /// The result is a vector of menu items.
217    ///
218    /// # Errors
219    /// Returns error if the request can't be transmitted to the client
220    /// or if an unknown response is received.
221    #[inline]
222    pub fn get_menu_items(&mut self, path: &Path) -> Result<Vec<MenuItem>> {
223        if !self
224            .registered_paths
225            .iter()
226            .any(|registered_path| path.starts_with(registered_path))
227        {
228            return Err(Error::NotInRegister(path.to_path_buf()));
229        }
230
231        self.socket.write_message(&Message {
232            verb: ApiVerb::GetMenuItems,
233            msg: path.to_string_lossy().to_string(),
234        })?;
235
236        let result = self
237            .read_filtered_responses()?
238            .into_iter()
239            .filter_map(|response| {
240                trace!("menu item response: {}", response.msg);
241                if matches!(response.msg.as_str(), "BEGIN" | "END") {
242                    None
243                } else {
244                    Some(
245                        MenuItem::try_from(response.msg.as_str())
246                            .map_err(Error::ParsingFailedMenuItem),
247                    )
248                }
249            })
250            .collect::<Result<Vec<_>>>()?;
251
252        Ok(result)
253    }
254
255    /// `retrieve_folder_status` checks the current state of a folder.
256    /// If the folder is not part of the register, an error is returned.
257    /// See the documentation of `FileStatus` for details.
258    ///
259    /// # Errors
260    /// Returns error if the request can't be transmitted to the client
261    /// or if an unknown response is received.
262    #[inline]
263    pub fn retrieve_folder_status(&mut self, path: &Path) -> Result<FileStatus> {
264        if !self
265            .registered_paths
266            .iter()
267            .any(|registered_path| path.starts_with(registered_path))
268        {
269            return Err(Error::NotInRegister(path.to_path_buf()));
270        }
271
272        self.socket.write_message(&Message {
273            verb: ApiVerb::RetrieveFolderStatus,
274            msg: path.to_string_lossy().to_string(),
275        })?;
276
277        let responses = self.read_filtered_responses()?;
278
279        for response in responses {
280            match response.msg.split_once(':') {
281                Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
282                    let status = FileStatus::try_from(responded_status)?;
283                    return Ok(status);
284                }
285                None | Some((_, _)) => continue,
286            }
287        }
288
289        Err(Error::NoResponse)
290    }
291
292    /// `retrieve_file_status` checks the current state of a folder.
293    /// If the folder is not part of the register, an error is returned.
294    /// See the documentation of `FileStatus` for details.
295    ///
296    /// # Errors
297    /// Returns error if the request can't be transmitted to the client
298    /// or if an unknown response is received.
299    #[inline]
300    pub fn retrieve_file_status(&mut self, path: &Path) -> Result<FileStatus> {
301        if !self
302            .registered_paths
303            .iter()
304            .any(|registered_path| path.starts_with(registered_path))
305        {
306            return Err(Error::NotInRegister(path.to_path_buf()));
307        }
308
309        self.socket.write_message(&Message {
310            verb: ApiVerb::RetrieveFileStatus,
311            msg: path.to_string_lossy().to_string(),
312        })?;
313
314        let responses = self.read_filtered_responses()?;
315
316        for response in responses {
317            match response.msg.split_once(':') {
318                Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
319                    let status = FileStatus::try_from(responded_status)?;
320                    return Ok(status);
321                }
322                None | Some((_, _)) => continue,
323            }
324        }
325
326        Err(Error::NoResponse)
327    }
328
329    /// `activity` displays the file-activity dialog.
330    ///
331    /// # Errors
332    /// Returns error if the request can't be transmitted to the client
333    /// or if an unknown response is received.
334    #[inline]
335    pub fn activity(&mut self, path: &Path) -> Result<()> {
336        if !self
337            .registered_paths
338            .iter()
339            .any(|registered_path| path.starts_with(registered_path))
340        {
341            return Err(Error::NotInRegister(path.to_path_buf()));
342        }
343
344        self.socket.write_message(&Message {
345            verb: ApiVerb::Activity,
346            msg: path.to_string_lossy().to_string(),
347        })?;
348
349        _ = self.read_filtered_responses()?;
350
351        Ok(())
352    }
353
354    /// `share` opens the sharing dialog of the Nextcloud client
355    /// if certain constraints are met.
356    /// E.g. you can't share a file, which is not synced to the
357    /// backend yet.
358    ///
359    /// # Errors
360    /// Returns error if the request can't be transmitted to the client
361    /// or if an unknown response is received.
362    #[inline]
363    pub fn share(&mut self, path: &Path) -> Result<()> {
364        if !self
365            .registered_paths
366            .iter()
367            .any(|registered_path| path.starts_with(registered_path))
368        {
369            return Err(Error::NotInRegister(path.to_path_buf()));
370        }
371
372        self.socket.write_message(&Message {
373            verb: ApiVerb::Share,
374            msg: path.to_string_lossy().to_string(),
375        })?;
376
377        let responses = self.read_filtered_responses()?;
378
379        for response in responses {
380            match response.msg.split_once(':') {
381                Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
382                    let status = ShareStatus::try_from(responded_status)?;
383                    return match status {
384                        ShareStatus::Nop => Err(Error::CannotShareFile),
385                        ShareStatus::NotConnected => Err(Error::NotConnected),
386                        ShareStatus::NotSynced => Err(Error::NotSynced),
387                        ShareStatus::CannotShareRoot => Err(Error::CannotShareRoot),
388                        ShareStatus::Ok => Ok(()),
389                    };
390                }
391                None | Some((_, _)) => continue,
392            }
393        }
394
395        Err(Error::NoResponse)
396    }
397
398    /// `manage_public_links` opens the sharing dialog to manage
399    /// active share links.
400    ///
401    /// # Errors
402    /// Returns error if the request can't be transmitted to the client
403    /// or if an unknown response is received.
404    #[inline]
405    pub fn manage_public_links(&mut self, path: &Path) -> Result<()> {
406        if !self
407            .registered_paths
408            .iter()
409            .any(|registered_path| path.starts_with(registered_path))
410        {
411            return Err(Error::NotInRegister(path.to_path_buf()));
412        }
413
414        self.socket.write_message(&Message {
415            verb: ApiVerb::ManagePublicLinks,
416            msg: path.to_string_lossy().to_string(),
417        })?;
418
419        let responses = self.read_filtered_responses()?;
420
421        for response in responses {
422            match response.msg.split_once(':') {
423                Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
424                    let status = ShareStatus::try_from(responded_status)?;
425                    return match status {
426                        ShareStatus::Nop => Err(Error::CannotShareFile),
427                        ShareStatus::NotConnected => Err(Error::NotConnected),
428                        ShareStatus::NotSynced => Err(Error::NotSynced),
429                        ShareStatus::CannotShareRoot => Err(Error::CannotShareRoot),
430                        ShareStatus::Ok => Ok(()),
431                    };
432                }
433                None | Some((_, _)) => continue,
434            }
435        }
436
437        Err(Error::NoResponse)
438    }
439
440    /// `copy_securefiledrop_link` creates a link to provide a file drop
441    /// location, which can't be read afterwards. In case of an internal
442    /// error, the sharing dialog is opened. The created link is placed
443    /// in the clipboard.
444    ///
445    /// # Errors
446    /// Returns error if the request can't be transmitted to the client
447    /// or if an unknown response is received.
448    #[inline]
449    pub fn copy_securefiledrop_link(&mut self, path: &Path) -> Result<()> {
450        if !self
451            .registered_paths
452            .iter()
453            .any(|registered_path| path.starts_with(registered_path))
454        {
455            return Err(Error::NotInRegister(path.to_path_buf()));
456        }
457
458        self.socket.write_message(&Message {
459            verb: ApiVerb::CopySecurefiledropLink,
460            msg: path.to_string_lossy().to_string(),
461        })?;
462
463        _ = self.read_filtered_responses()?;
464
465        Ok(())
466    }
467
468    /// `copy_public_link` creates a link to share publicly.
469    /// In case of an internal error, the sharing dialog is opened.
470    /// The created link is placed in the clipboard.
471    ///
472    /// # Errors
473    /// Returns error if the request can't be transmitted to the client
474    /// or if an unknown response is received.
475    #[inline]
476    pub fn copy_public_link(&mut self, path: &Path) -> Result<()> {
477        if !self
478            .registered_paths
479            .iter()
480            .any(|registered_path| path.starts_with(registered_path))
481        {
482            return Err(Error::NotInRegister(path.to_path_buf()));
483        }
484
485        self.socket.write_message(&Message {
486            verb: ApiVerb::CopyPublicLink,
487            msg: path.to_string_lossy().to_string(),
488        })?;
489
490        _ = self.read_filtered_responses()?;
491
492        Ok(())
493    }
494
495    /// `copy_private_link` creates a link to share internally.
496    /// In case of an internal error, the sharing dialog is opened.
497    /// The created link is placed in the clipboard.
498    ///
499    /// # Errors
500    /// Returns error if the request can't be transmitted to the client
501    /// or if an unknown response is received.
502    #[inline]
503    pub fn copy_private_link(&mut self, path: &Path) -> Result<()> {
504        if !self
505            .registered_paths
506            .iter()
507            .any(|registered_path| path.starts_with(registered_path))
508        {
509            return Err(Error::NotInRegister(path.to_path_buf()));
510        }
511
512        self.socket.write_message(&Message {
513            verb: ApiVerb::CopyPublicLink,
514            msg: path.to_string_lossy().to_string(),
515        })?;
516
517        _ = self.read_filtered_responses()?;
518
519        Ok(())
520    }
521
522    /// `email_private_link` creates a link to share internally.
523    /// In case of an internal error, the sharing dialog is opened.
524    /// The default e-mail client is opened with a prepared message.
525    ///
526    /// # Errors
527    /// Returns error if the request can't be transmitted to the client
528    /// or if an unknown response is received.
529    #[inline]
530    pub fn email_private_link(&mut self, path: &Path) -> Result<()> {
531        if !self
532            .registered_paths
533            .iter()
534            .any(|registered_path| path.starts_with(registered_path))
535        {
536            return Err(Error::NotInRegister(path.to_path_buf()));
537        }
538
539        self.socket.write_message(&Message {
540            verb: ApiVerb::EmailPrivateLink,
541            msg: path.to_string_lossy().to_string(),
542        })?;
543
544        _ = self.read_filtered_responses()?;
545
546        Ok(())
547    }
548
549    /// `open_private_link` creates a link to share internally.
550    /// In case of an internal error, the sharing dialog is opened.
551    /// The default browser is opened with generated link.
552    ///
553    /// # Errors
554    /// Returns error if the request can't be transmitted to the client
555    /// or if an unknown response is received.
556    #[inline]
557    pub fn open_private_link(&mut self, path: &Path) -> Result<()> {
558        if !self
559            .registered_paths
560            .iter()
561            .any(|registered_path| path.starts_with(registered_path))
562        {
563            return Err(Error::NotInRegister(path.to_path_buf()));
564        }
565
566        self.socket.write_message(&Message {
567            verb: ApiVerb::OpenPrivateLink,
568            msg: path.to_string_lossy().to_string(),
569        })?;
570
571        _ = self.read_filtered_responses()?;
572
573        Ok(())
574    }
575
576    /// `make_available_locally` configures the file to be synced to
577    /// the local FS as soon as possible.
578    ///
579    /// # Errors
580    /// Returns error if the request can't be transmitted to the client
581    /// or if an unknown response is received.
582    #[inline]
583    pub fn make_available_locally(&mut self, path: &Path) -> Result<()> {
584        if !self
585            .registered_paths
586            .iter()
587            .any(|registered_path| path.starts_with(registered_path))
588        {
589            return Err(Error::NotInRegister(path.to_path_buf()));
590        }
591
592        self.socket.write_message(&Message {
593            verb: ApiVerb::MakeAvailableLocally,
594            msg: path.to_string_lossy().to_string(),
595        })?;
596
597        _ = self.read_filtered_responses()?;
598
599        Ok(())
600    }
601
602    /// `make_online_only` configures the file to be virtually available.
603    /// The file or folder will be downloaded on access. So permanent
604    /// space occupation is minimized but latency shall be considered.
605    ///
606    /// # Errors
607    /// Returns error if the request can't be transmitted to the client
608    /// or if an unknown response is received.
609    #[inline]
610    pub fn make_online_only(&mut self, path: &Path) -> Result<()> {
611        if !self
612            .registered_paths
613            .iter()
614            .any(|registered_path| path.starts_with(registered_path))
615        {
616            return Err(Error::NotInRegister(path.to_path_buf()));
617        }
618
619        self.socket.write_message(&Message {
620            verb: ApiVerb::MakeOnlineOnly,
621            msg: path.to_string_lossy().to_string(),
622        })?;
623
624        _ = self.read_filtered_responses()?;
625
626        Ok(())
627    }
628}
629
630#[cfg(test)]
631#[expect(clippy::unwrap_used, clippy::panic, reason = "for testing we panic")]
632mod tests {
633    use core::time::Duration;
634    use std::{
635        io::{prelude::*, BufReader, ErrorKind},
636        os::unix::net::UnixListener,
637        thread::{sleep, Builder as ThreadBuilder, JoinHandle},
638    };
639
640    use tempfile::TempDir;
641
642    use super::*;
643
644    #[test]
645    fn test_fetch_version() {
646        let temp_dir = TempDir::new().unwrap();
647        let temp_path = temp_dir.path().join("nextcloud_socket");
648
649        let server = handle_connection(
650            &temp_path,
651            "VERSION:".to_owned(),
652            "VERSION:v1.3.37\n".to_owned(),
653        );
654
655        let mut api = Api::new_test(&temp_path).unwrap();
656        let version = api.version().unwrap();
657
658        server.join().unwrap();
659
660        assert_eq!("v1.3.37".to_owned(), version);
661    }
662
663    #[test]
664    fn test_fetch_menu_items() {
665        let temp_dir = TempDir::new().unwrap();
666        let temp_path = temp_dir.path().join("nextcloud_socket");
667
668        let server = handle_connection(&temp_path,"GET_MENU_ITEMS:/tmp/NextCloud/test/document.pdf".to_owned(), "GET_MENU_ITEMS:BEGIN\nMENU_ITEM:ACTIVITY::activity\nMENU_ITEM:OPEN_PRIVATE_LINK::open in browser\nMENU_ITEM:SHARE::share\nMENU_ITEM:COPY_PUBLIC_LINK::copy public link\nMENU_ITEM:COPY_PRIVATE_LINK::copy private link\nGET_MENU_ITEMS:END\n".to_owned());
669
670        let mut api = Api::new_test(&temp_path).unwrap();
671        let items = api
672            .get_menu_items(Path::new("/tmp/NextCloud/test/document.pdf"))
673            .unwrap();
674
675        server.join().unwrap();
676
677        assert_eq!(5, items.len(), "wrong number of menu items returned");
678    }
679
680    fn handle_connection(temp_path: &Path, request: String, response: String) -> JoinHandle<()> {
681        let listener = UnixListener::bind(temp_path).unwrap();
682
683        let server = ThreadBuilder::new()
684            .name("socket server".to_owned())
685            .spawn(move || {
686                trace!("spawned server");
687                let (mut unix_stream, _) = listener.accept().unwrap();
688                unix_stream
689                    .set_read_timeout(Some(Duration::from_millis(100)))
690                    .unwrap();
691
692                let mut reader = BufReader::new(unix_stream.try_clone().unwrap());
693
694                trace!("accepted incomming connection");
695                unix_stream
696                    .write_all("REGISTER_PATH:/tmp/NextCloud\n".to_owned().as_bytes())
697                    .unwrap();
698
699                loop {
700                    let mut buf = String::new();
701                    match reader.read_line(&mut buf) {
702                        Ok(0) => continue,
703                        Ok(_) => {}
704                        Err(err) if err.kind() == ErrorKind::WouldBlock => continue,
705                        Err(err) => panic!("failed to read line: {err}"),
706                    }
707
708                    let trimmed = buf.trim();
709                    trace!("received request: {trimmed}");
710
711                    assert_eq!(request.as_str(), trimmed, "unexpected request: {trimmed}");
712
713                    break;
714                }
715
716                unix_stream.write_all(response.as_bytes()).unwrap();
717
718                // sleep to let response handling take action before
719                // removing the socket
720                sleep(Duration::from_secs(1));
721
722                trace!("shutting down");
723            })
724            .unwrap();
725
726        server
727    }
728}