#![allow(
clippy::blanket_clippy_restriction_lints,
reason = "I can't allow this in the cargo.toml for some reason, so I add it here."
)]
use core::{result::Result as StdResult, time::Duration};
use std::path::{Path, PathBuf};
use log::trace;
use thiserror::Error as ThisError;
use crate::{
api_verb::ApiVerb,
file_status::{Error as FileStatusError, FileStatus},
menu_item::{Error as MenuItemError, MenuItem},
message::Message,
share_status::{Error as ShareStatusError, ShareStatus},
socket::{Error as NextCloudClientSocketError, NextCloudClientSocket},
};
mod api_verb;
mod file_status;
mod menu_item;
mod message;
mod share_status;
mod socket;
pub type Result<T> = StdResult<T, Error>;
#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
#[error("failed to open socket: {0}")]
SocketFail(#[from] NextCloudClientSocketError),
#[error("requested path/file is not registered in NextCloud: {0}")]
NotInRegister(PathBuf),
#[error("no response")]
NoResponse,
#[error("failed to parse file status: {0}")]
ParsingFailedFileStatus(#[from] FileStatusError),
#[error("failed to parse share status: {0}")]
ParsingFailedShareStatus(#[from] ShareStatusError),
#[error("failed to parse menu item: {0}")]
ParsingFailedMenuItem(#[from] MenuItemError),
#[error("file can't be shared due to missing options or the file is not in the correct path")]
CannotShareFile,
#[error("you can't share the root of your account")]
CannotShareRoot,
#[error("the client is not connected to a backend")]
NotConnected,
#[error("the file is not synced yet")]
NotSynced,
}
pub struct Api {
socket: NextCloudClientSocket,
registered_paths: Vec<PathBuf>,
}
impl Api {
#[inline]
pub fn new() -> Result<Self> {
Api::build(NextCloudClientSocket::new()?)
}
#[cfg(test)]
pub(crate) fn new_test(socket_file: &Path) -> Result<Self> {
Api::build(NextCloudClientSocket::new_test(socket_file)?)
}
#[cfg_attr(
not(test),
expect(clippy::single_call_fn, reason = "abstraction for test mocks")
)]
fn build(socket: NextCloudClientSocket) -> Result<Self> {
let mut result = Self {
socket,
registered_paths: Vec::new(),
};
let initial_messages = result.read_filtered_responses()?;
for msg in initial_messages {
trace!("initial: {msg}");
}
Ok(result)
}
fn read_filtered_responses(&mut self) -> Result<Vec<Message>> {
let responses = self.socket.read_until_settled(Duration::from_millis(200))?;
let (registered_paths, other): (Vec<Message>, Vec<Message>) = responses
.into_iter()
.inspect(|response| {
trace!("received response: {response}");
})
.partition(|response| matches!(response.verb, ApiVerb::RegisterPath));
self.registered_paths.extend(
registered_paths
.into_iter()
.map(|path_response| PathBuf::from(path_response.msg)),
);
Ok(other)
}
#[inline]
pub fn version(&mut self) -> Result<String> {
self.socket.write_message(&Message {
verb: ApiVerb::Version,
msg: String::default(),
})?;
let version = loop {
let responses = self.read_filtered_responses()?;
match responses.into_iter().find_map(|response| {
(matches!(response.verb, ApiVerb::Version)).then_some(response.msg)
}) {
None => {}
Some(version) => break version,
}
};
trace!("version response: {version}");
Ok(version)
}
#[inline]
pub fn get_strings(&mut self) -> Result<Vec<String>> {
self.socket.write_message(&Message {
verb: ApiVerb::GetStrings,
msg: String::default(),
})?;
Ok(self
.read_filtered_responses()?
.into_iter()
.filter_map(|response| {
trace!("string response: {}", response.msg);
if matches!(response.msg.as_str(), "BEGIN" | "END") {
None
} else {
Some(response.msg)
}
})
.collect::<Vec<_>>())
}
#[inline]
pub fn get_menu_items(&mut self, path: &Path) -> Result<Vec<MenuItem>> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::GetMenuItems,
msg: path.to_string_lossy().to_string(),
})?;
let result = self
.read_filtered_responses()?
.into_iter()
.filter_map(|response| {
trace!("menu item response: {}", response.msg);
if matches!(response.msg.as_str(), "BEGIN" | "END") {
None
} else {
Some(
MenuItem::try_from(response.msg.as_str())
.map_err(Error::ParsingFailedMenuItem),
)
}
})
.collect::<Result<Vec<_>>>()?;
Ok(result)
}
#[inline]
pub fn retrieve_folder_status(&mut self, path: &Path) -> Result<FileStatus> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::RetrieveFolderStatus,
msg: path.to_string_lossy().to_string(),
})?;
let responses = self.read_filtered_responses()?;
for response in responses {
match response.msg.split_once(':') {
Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
let status = FileStatus::try_from(responded_status)?;
return Ok(status);
}
None | Some((_, _)) => continue,
}
}
Err(Error::NoResponse)
}
#[inline]
pub fn retrieve_file_status(&mut self, path: &Path) -> Result<FileStatus> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::RetrieveFileStatus,
msg: path.to_string_lossy().to_string(),
})?;
let responses = self.read_filtered_responses()?;
for response in responses {
match response.msg.split_once(':') {
Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
let status = FileStatus::try_from(responded_status)?;
return Ok(status);
}
None | Some((_, _)) => continue,
}
}
Err(Error::NoResponse)
}
#[inline]
pub fn activity(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::Activity,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn share(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::Share,
msg: path.to_string_lossy().to_string(),
})?;
let responses = self.read_filtered_responses()?;
for response in responses {
match response.msg.split_once(':') {
Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
let status = ShareStatus::try_from(responded_status)?;
return match status {
ShareStatus::Nop => Err(Error::CannotShareFile),
ShareStatus::NotConnected => Err(Error::NotConnected),
ShareStatus::NotSynced => Err(Error::NotSynced),
ShareStatus::CannotShareRoot => Err(Error::CannotShareRoot),
ShareStatus::Ok => Ok(()),
};
}
None | Some((_, _)) => continue,
}
}
Err(Error::NoResponse)
}
#[inline]
pub fn manage_public_links(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::ManagePublicLinks,
msg: path.to_string_lossy().to_string(),
})?;
let responses = self.read_filtered_responses()?;
for response in responses {
match response.msg.split_once(':') {
Some((responded_status, responded_path)) if Path::new(responded_path) == path => {
let status = ShareStatus::try_from(responded_status)?;
return match status {
ShareStatus::Nop => Err(Error::CannotShareFile),
ShareStatus::NotConnected => Err(Error::NotConnected),
ShareStatus::NotSynced => Err(Error::NotSynced),
ShareStatus::CannotShareRoot => Err(Error::CannotShareRoot),
ShareStatus::Ok => Ok(()),
};
}
None | Some((_, _)) => continue,
}
}
Err(Error::NoResponse)
}
#[inline]
pub fn copy_securefiledrop_link(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::CopySecurefiledropLink,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn copy_public_link(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::CopyPublicLink,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn copy_private_link(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::CopyPublicLink,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn email_private_link(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::EmailPrivateLink,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn open_private_link(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::OpenPrivateLink,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn make_available_locally(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::MakeAvailableLocally,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
#[inline]
pub fn make_online_only(&mut self, path: &Path) -> Result<()> {
if !self
.registered_paths
.iter()
.any(|registered_path| path.starts_with(registered_path))
{
return Err(Error::NotInRegister(path.to_path_buf()));
}
self.socket.write_message(&Message {
verb: ApiVerb::MakeOnlineOnly,
msg: path.to_string_lossy().to_string(),
})?;
_ = self.read_filtered_responses()?;
Ok(())
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::panic, reason = "for testing we panic")]
mod tests {
use core::time::Duration;
use std::{
io::{prelude::*, BufReader, ErrorKind},
os::unix::net::UnixListener,
thread::{sleep, Builder as ThreadBuilder, JoinHandle},
};
use tempfile::TempDir;
use super::*;
#[test]
fn test_fetch_version() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().join("nextcloud_socket");
let server = handle_connection(
&temp_path,
"VERSION:".to_owned(),
"VERSION:v1.3.37\n".to_owned(),
);
let mut api = Api::new_test(&temp_path).unwrap();
let version = api.version().unwrap();
server.join().unwrap();
assert_eq!("v1.3.37".to_owned(), version);
}
#[test]
fn test_fetch_menu_items() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().join("nextcloud_socket");
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());
let mut api = Api::new_test(&temp_path).unwrap();
let items = api
.get_menu_items(Path::new("/tmp/NextCloud/test/document.pdf"))
.unwrap();
server.join().unwrap();
assert_eq!(5, items.len(), "wrong number of menu items returned");
}
fn handle_connection(temp_path: &Path, request: String, response: String) -> JoinHandle<()> {
let listener = UnixListener::bind(temp_path).unwrap();
let server = ThreadBuilder::new()
.name("socket server".to_owned())
.spawn(move || {
trace!("spawned server");
let (mut unix_stream, _) = listener.accept().unwrap();
unix_stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let mut reader = BufReader::new(unix_stream.try_clone().unwrap());
trace!("accepted incomming connection");
unix_stream
.write_all("REGISTER_PATH:/tmp/NextCloud\n".to_owned().as_bytes())
.unwrap();
loop {
let mut buf = String::new();
match reader.read_line(&mut buf) {
Ok(0) => continue,
Ok(_) => {}
Err(err) if err.kind() == ErrorKind::WouldBlock => continue,
Err(err) => panic!("failed to read line: {err}"),
}
let trimmed = buf.trim();
trace!("received request: {trimmed}");
assert_eq!(request.as_str(), trimmed, "unexpected request: {trimmed}");
break;
}
unix_stream.write_all(response.as_bytes()).unwrap();
sleep(Duration::from_secs(1));
trace!("shutting down");
})
.unwrap();
server
}
}