use nextcloud_dav::xml::response::{first_element, has_element, text_of};
use nextcloud_dav::xml::{Element, MissingElement, XmlWriter};
use nextcloud_sdk_core::DecodeError;
use crate::item::TrashItem;
pub(crate) const TARGET: &str = "TrashItem";
pub(crate) fn build_propfind_body() -> String {
let mut writer = XmlWriter::new();
writer.prolog();
writer.open_with(
"d:propfind",
&[
("xmlns:d", "DAV:"),
("xmlns:oc", "http://owncloud.org/ns"),
("xmlns:nc", "http://nextcloud.org/ns"),
],
);
writer.open("d:prop");
writer.empty("d:getlastmodified");
writer.empty("d:resourcetype");
writer.empty("oc:trashbin-filename");
writer.empty("oc:trashbin-original-location");
writer.empty("oc:trashbin-deletion-time");
writer.empty("nc:trashbin-filename");
writer.empty("nc:trashbin-original-location");
writer.empty("nc:trashbin-deletion-time");
writer.close("d:prop");
writer.close("d:propfind");
writer.into_string()
}
pub(crate) fn decode_multistatus(user_id: &str, body: &str) -> Result<Vec<TrashItem>, DecodeError> {
let multistatus = first_element(body, "d:multistatus")
.or_else(|| first_element(body, "D:multistatus"))
.ok_or_else(|| DecodeError::new(TARGET, MissingElement::new("d:multistatus")))?;
let mut responses = multistatus.children("d:response");
if responses.is_empty() {
responses = multistatus.children("D:response");
}
let mut items = Vec::new();
for response in responses {
if let Some(item) = decode_response(user_id, &response)? {
items.push(item);
}
}
Ok(items)
}
fn decode_response(
user_id: &str,
response: &Element<'_>,
) -> Result<Option<TrashItem>, DecodeError> {
let href = response
.child("d:href")
.or_else(|| response.child("D:href"))
.map(|element| element.text())
.filter(|href| !href.is_empty())
.ok_or_else(|| DecodeError::new(TARGET, MissingElement::new("d:href")))?;
if is_trash_root(user_id, &href) {
return Ok(None);
}
let propstats = {
let mut ps = response.children("d:propstat");
if ps.is_empty() {
ps = response.children("D:propstat");
}
ps
};
let props: Vec<Element<'_>> = propstats
.into_iter()
.filter(is_successful_propstat)
.filter_map(|propstat| {
propstat
.child("d:prop")
.or_else(|| propstat.child("D:prop"))
})
.collect();
let filename = find_property(
&props,
&[
"oc:trashbin-filename",
"nc:trashbin-filename",
"trashbin-filename",
],
);
let original_location = find_property(
&props,
&[
"oc:trashbin-original-location",
"nc:trashbin-original-location",
"trashbin-original-location",
],
);
let deletion_time = find_property(
&props,
&[
"oc:trashbin-deletion-time",
"nc:trashbin-deletion-time",
"trashbin-deletion-time",
],
)
.and_then(|value| value.trim().parse::<u64>().ok());
let last_modified = find_property(
&props,
&["d:getlastmodified", "D:getlastmodified", "getlastmodified"],
);
let is_collection = props.iter().any(|prop| {
has_element(prop.inner(), "d:collection")
|| has_element(prop.inner(), "D:collection")
|| has_element(prop.inner(), "collection")
});
let trimmed_href = href.trim_end_matches('/');
let id = trimmed_href.rsplit('/').next().unwrap_or("").to_owned();
if filename.is_none() && deletion_time.is_none() && id == "trash" {
return Ok(None);
}
let filename = filename.unwrap_or_else(|| id.clone());
let original_location = original_location.unwrap_or_else(|| filename.clone());
Ok(Some(TrashItem {
id,
href,
filename,
original_location,
deletion_time,
last_modified,
is_collection,
}))
}
fn is_trash_root(user_id: &str, href: &str) -> bool {
let trimmed = href.trim_end_matches('/');
let expected_suffix = format!("/trashbin/{user_id}/trash");
trimmed.ends_with(&expected_suffix)
}
fn is_successful_propstat(propstat: &Element<'_>) -> bool {
let status_child = propstat
.child("d:status")
.or_else(|| propstat.child("D:status"));
match status_child {
None => true,
Some(status) => status.text().contains(" 200 "),
}
}
fn find_property(props: &[Element<'_>], names: &[&str]) -> Option<String> {
for prop in props {
for &name in names {
if let Some(text) = text_of(prop.inner(), name) {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const TRASH_MULTISTATUS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:response>
<d:href>/remote.php/dav/trashbin/alice/trash/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/trashbin/alice/trash/notes.txt.d1600000000</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Wed, 21 Oct 2020 07:28:00 GMT</d:getlastmodified>
<d:resourcetype/>
<oc:trashbin-filename>notes.txt</oc:trashbin-filename>
<oc:trashbin-original-location>Documents/notes.txt</oc:trashbin-original-location>
<oc:trashbin-deletion-time>1600000000</oc:trashbin-deletion-time>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/trashbin/alice/trash/Holiday.d1600000100/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Wed, 21 Oct 2020 07:30:00 GMT</d:getlastmodified>
<d:resourcetype><d:collection/></d:resourcetype>
<nc:trashbin-filename>Holiday</nc:trashbin-filename>
<nc:trashbin-original-location>Photos/Holiday</nc:trashbin-original-location>
<nc:trashbin-deletion-time>1600000100</nc:trashbin-deletion-time>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
#[test]
fn decodes_items_and_filters_root() {
let items = decode_multistatus("alice", TRASH_MULTISTATUS).expect("decodes");
assert_eq!(items.len(), 2);
let file = &items[0];
assert_eq!(file.id, "notes.txt.d1600000000");
assert_eq!(file.filename, "notes.txt");
assert_eq!(file.original_location, "Documents/notes.txt");
assert_eq!(file.deletion_time, Some(1600000000));
assert_eq!(
file.last_modified.as_deref(),
Some("Wed, 21 Oct 2020 07:28:00 GMT")
);
assert!(!file.is_collection);
let folder = &items[1];
assert_eq!(folder.id, "Holiday.d1600000100");
assert_eq!(folder.filename, "Holiday");
assert_eq!(folder.original_location, "Photos/Holiday");
assert_eq!(folder.deletion_time, Some(1600000100));
assert!(folder.is_collection);
}
#[test]
fn decodes_empty_trash() {
let empty_body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/trashbin/alice/trash/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let items = decode_multistatus("alice", empty_body).expect("decodes");
assert_eq!(items.len(), 0);
}
#[test]
fn propfind_body_contains_required_namespaces_and_properties() {
let body = build_propfind_body();
assert!(body.contains("xmlns:d=\"DAV:\""));
assert!(body.contains("xmlns:oc=\"http://owncloud.org/ns\""));
assert!(body.contains("xmlns:nc=\"http://nextcloud.org/ns\""));
assert!(body.contains("<d:getlastmodified/>"));
assert!(body.contains("<d:resourcetype/>"));
assert!(body.contains("<oc:trashbin-filename/>"));
assert!(body.contains("<oc:trashbin-original-location/>"));
assert!(body.contains("<oc:trashbin-deletion-time/>"));
}
#[test]
fn rejects_missing_multistatus() {
let err = decode_multistatus("alice", "<html>Not found</html>").unwrap_err();
assert_eq!(err.target(), TARGET);
}
#[test]
fn rejects_missing_href() {
let bad_xml = r#"<d:multistatus xmlns:d="DAV:"><d:response>
<d:propstat><d:prop/></d:propstat>
</d:response></d:multistatus>"#;
let err = decode_multistatus("alice", bad_xml).unwrap_err();
assert_eq!(err.target(), TARGET);
}
}