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)]
5use 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
48pub type Result<T> = StdResult<T, Error>;
50
51#[derive(Debug, ThisError)]
53#[non_exhaustive]
54pub enum Error {
55 #[error("failed to open socket: {0}")]
57 SocketFail(#[from] NextCloudClientSocketError),
58
59 #[error("requested path/file is not registered in NextCloud: {0}")]
62 NotInRegister(PathBuf),
63
64 #[error("no response")]
66 NoResponse,
67
68 #[error("failed to parse file status: {0}")]
70 ParsingFailedFileStatus(#[from] FileStatusError),
71
72 #[error("failed to parse share status: {0}")]
74 ParsingFailedShareStatus(#[from] ShareStatusError),
75
76 #[error("failed to parse menu item: {0}")]
78 ParsingFailedMenuItem(#[from] MenuItemError),
79
80 #[error("file can't be shared due to missing options or the file is not in the correct path")]
82 CannotShareFile,
83
84 #[error("you can't share the root of your account")]
86 CannotShareRoot,
87
88 #[error("the client is not connected to a backend")]
90 NotConnected,
91
92 #[error("the file is not synced yet")]
94 NotSynced,
95}
96
97pub struct Api {
99 socket: NextCloudClientSocket,
100 registered_paths: Vec<PathBuf>,
101}
102
103impl Api {
104 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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(Duration::from_secs(1));
721
722 trace!("shutting down");
723 })
724 .unwrap();
725
726 server
727 }
728}