JMAP for File Storage implementation (closes #2218)

This commit is contained in:
mdecimus
2025-10-03 16:52:34 +02:00
parent d000b5975a
commit c47413a42b
32 changed files with 1683 additions and 38 deletions

View File

@@ -558,7 +558,7 @@ impl DavResourcePath<'_> {
#[inline(always)]
pub fn size(&self) -> u32 {
self.resource.size()
self.resource.size().unwrap_or_default()
}
}
@@ -692,6 +692,20 @@ impl DavResource {
}
}
pub fn parent_id(&self) -> Option<u32> {
match &self.data {
DavResourceMetadata::File { parent_id, .. } => *parent_id,
DavResourceMetadata::CalendarEvent { names, .. } => {
names.first().map(|name| name.parent_id)
}
DavResourceMetadata::ContactCard { names } => names.first().map(|name| name.parent_id),
DavResourceMetadata::CalendarScheduling { names } if names.is_empty() => {
Some(SCHEDULE_INBOX_ID)
}
_ => None,
}
}
pub fn child_names(&self) -> Option<&[DavName]> {
match &self.data {
DavResourceMetadata::CalendarEvent { names, .. } => Some(names.as_slice()),
@@ -782,10 +796,10 @@ impl DavResource {
}
}
pub fn size(&self) -> u32 {
pub fn size(&self) -> Option<u32> {
match &self.data {
DavResourceMetadata::File { size, .. } => size.unwrap_or_default(),
_ => 0,
DavResourceMetadata::File { size, .. } => *size,
_ => None,
}
}

View File

@@ -263,6 +263,11 @@ impl Permission {
Permission::JmapContactCardSet => "Create or update contact cards via JMAP",
Permission::JmapContactCardCopy => "Copy contact cards to new locations via JMAP",
Permission::JmapContactCardParse => "Parse contact cards via JMAP",
Permission::JmapFileNodeGet => "Retrieve file nodes via JMAP",
Permission::JmapFileNodeSet => "Create or update file nodes via JMAP",
Permission::JmapFileNodeChanges => "Track file node changes via JMAP",
Permission::JmapFileNodeQuery => "Search for file nodes matching criteria via JMAP",
Permission::JmapFileNodeQueryChanges => "Track file node query changes via JMAP",
}
}
}

View File

@@ -1475,6 +1475,11 @@ impl Permission {
| Permission::JmapContactCardSet
| Permission::JmapContactCardCopy
| Permission::JmapContactCardParse
| Permission::JmapFileNodeGet
| Permission::JmapFileNodeSet
| Permission::JmapFileNodeChanges
| Permission::JmapFileNodeQuery
| Permission::JmapFileNodeQueryChanges
)
}

View File

@@ -391,6 +391,12 @@ pub enum Permission {
JmapContactCardSet,
JmapContactCardCopy,
JmapContactCardParse,
JmapFileNodeGet,
JmapFileNodeSet,
JmapFileNodeChanges,
JmapFileNodeQuery,
JmapFileNodeQueryChanges,
// WARNING: add new ids at the end (TODO: use static ids)
}

View File

@@ -97,6 +97,28 @@ impl DestroyArchive<Vec<u32>> {
) -> trc::Result<()> {
// Process deletions
let mut batch = BatchBuilder::new();
self.delete_batch(server, access_token, account_id, delete_path, &mut batch)
.await?;
// Write changes
if !batch.is_empty() {
server
.commit_batch(batch)
.await
.caused_by(trc::location!())?;
}
Ok(())
}
pub async fn delete_batch(
self,
server: &Server,
access_token: &AccessToken,
account_id: u32,
delete_path: Option<String>,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
// Process deletions
batch
.with_account_id(account_id)
.with_collection(Collection::FileNode);
@@ -121,15 +143,10 @@ impl DestroyArchive<Vec<u32>> {
}
}
// Write changes
if !batch.is_empty() {
if let Some(delete_path) = delete_path {
batch.log_vanished_item(VanishedCollection::FileNode, delete_path);
}
server
.commit_batch(batch)
.await
.caused_by(trc::location!())?;
if !batch.is_empty()
&& let Some(delete_path) = delete_path
{
batch.log_vanished_item(VanishedCollection::FileNode, delete_path);
}
Ok(())

View File

@@ -85,6 +85,8 @@ pub enum SetErrorType {
ScriptIsActive,
#[serde(rename = "addressBookHasContents")]
AddressBookHasContents,
#[serde(rename = "nodeHasChildren")]
NodeHasChildren,
}
impl SetErrorType {
@@ -116,6 +118,7 @@ impl SetErrorType {
SetErrorType::InvalidScript => "invalidScript",
SetErrorType::ScriptIsActive => "scriptIsActive",
SetErrorType::AddressBookHasContents => "addressBookHasContents",
SetErrorType::NodeHasChildren => "nodeHasChildren",
}
}
}
@@ -193,6 +196,10 @@ impl<T: Property> SetError<T> {
Self::new(SetErrorType::AddressBookHasContents)
.with_description("Address book is not empty.")
}
pub fn node_has_children() -> Self {
Self::new(SetErrorType::NodeHasChildren).with_description("File node has children.")
}
}
impl<T: Property> From<T> for InvalidProperty<T> {

View File

@@ -0,0 +1,590 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
object::{
AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref,
},
request::{MaybeInvalid, deserialize::DeserializeArguments},
types::date::UTCDate,
};
use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::{acl::Acl, blob::BlobId, id::Id};
use utils::glob::GlobPattern;
#[derive(Debug, Clone, Default)]
pub struct FileNode;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FileNodeProperty {
Id,
ParentId,
BlobId,
Size,
Name,
Type,
Created,
Modified,
Accessed,
Executable,
MyRights,
ShareWith,
// Other
IdValue(Id),
Rights(FileNodeRight),
Pointer(JsonPointer<FileNodeProperty>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FileNodeRight {
MayRead,
MayWrite,
MayShare,
MayDelete,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FileNodeValue {
Id(Id),
Date(UTCDate),
BlobId(BlobId),
IdReference(String),
}
impl Property for FileNodeProperty {
fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
let allow_patch = key.is_none();
if let Some(Key::Property(key)) = key {
match key.patch_or_prop() {
FileNodeProperty::ShareWith => {
Id::from_str(value).ok().map(FileNodeProperty::IdValue)
}
_ => FileNodeProperty::parse(value, allow_patch),
}
} else {
FileNodeProperty::parse(value, allow_patch)
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
FileNodeProperty::Id => "id",
FileNodeProperty::ParentId => "parentId",
FileNodeProperty::BlobId => "blobId",
FileNodeProperty::Size => "size",
FileNodeProperty::Name => "name",
FileNodeProperty::Type => "type",
FileNodeProperty::Created => "created",
FileNodeProperty::Modified => "modified",
FileNodeProperty::Accessed => "accessed",
FileNodeProperty::Executable => "executable",
FileNodeProperty::MyRights => "myRights",
FileNodeProperty::ShareWith => "shareWith",
FileNodeProperty::Rights(file_right) => file_right.as_str(),
FileNodeProperty::Pointer(json_pointer) => return json_pointer.to_string().into(),
FileNodeProperty::IdValue(id) => return id.to_string().into(),
}
.into()
}
}
impl FileNodeRight {
pub fn as_str(&self) -> &'static str {
match self {
FileNodeRight::MayRead => "mayRead",
FileNodeRight::MayWrite => "mayWrite",
FileNodeRight::MayShare => "mayShare",
FileNodeRight::MayDelete => "mayDelete",
}
}
}
impl Element for FileNodeValue {
type Property = FileNodeProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
if let Key::Property(prop) = key {
match prop.patch_or_prop() {
FileNodeProperty::Id | FileNodeProperty::ParentId => match parse_ref(value) {
MaybeReference::Value(v) => Some(FileNodeValue::Id(v)),
MaybeReference::Reference(v) => Some(FileNodeValue::IdReference(v)),
MaybeReference::ParseError => None,
},
FileNodeProperty::BlobId => match parse_ref(value) {
MaybeReference::Value(v) => Some(FileNodeValue::BlobId(v)),
MaybeReference::Reference(v) => Some(FileNodeValue::IdReference(v)),
MaybeReference::ParseError => None,
},
FileNodeProperty::Created
| FileNodeProperty::Modified
| FileNodeProperty::Accessed => {
UTCDate::from_str(value).ok().map(FileNodeValue::Date)
}
_ => None,
}
} else {
None
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
FileNodeValue::Id(id) => id.to_string().into(),
FileNodeValue::Date(utcdate) => utcdate.to_string().into(),
FileNodeValue::BlobId(blob_id) => blob_id.to_string().into(),
FileNodeValue::IdReference(r) => format!("#{r}").into(),
}
}
}
impl FileNodeProperty {
fn parse(value: &str, allow_patch: bool) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => FileNodeProperty::Id,
b"parentId" => FileNodeProperty::ParentId,
b"blobId" => FileNodeProperty::BlobId,
b"size" => FileNodeProperty::Size,
b"name" => FileNodeProperty::Name,
b"type" => FileNodeProperty::Type,
b"created" => FileNodeProperty::Created,
b"modified" => FileNodeProperty::Modified,
b"accessed" => FileNodeProperty::Accessed,
b"executable" => FileNodeProperty::Executable,
b"myRights" => FileNodeProperty::MyRights,
b"shareWith" => FileNodeProperty::ShareWith,
b"mayRead" => FileNodeProperty::Rights(FileNodeRight::MayRead),
b"mayWrite" => FileNodeProperty::Rights(FileNodeRight::MayWrite),
b"mayShare" => FileNodeProperty::Rights(FileNodeRight::MayShare),
b"mayDelete" => FileNodeProperty::Rights(FileNodeRight::MayDelete)
)
.or_else(|| {
if allow_patch && value.contains('/') {
FileNodeProperty::Pointer(JsonPointer::parse(value)).into()
} else {
None
}
})
}
fn patch_or_prop(&self) -> &FileNodeProperty {
if let FileNodeProperty::Pointer(ptr) = self
&& let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last()
{
prop
} else {
self
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FileNodeSetArguments {
pub on_destroy_remove_children: Option<bool>,
}
impl<'x> DeserializeArguments<'x> for FileNodeSetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'x>,
{
if key == "onDestroyRemoveChildren" {
self.on_destroy_remove_children = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct FileNodeQueryArguments {
pub depth: Option<u32>,
}
impl<'x> DeserializeArguments<'x> for FileNodeQueryArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'x>,
{
if key == "depth" {
self.depth = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl serde::Serialize for FileNodeProperty {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.to_cow().as_ref())
}
}
impl FromStr for FileNodeProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
FileNodeProperty::parse(s, false).ok_or(())
}
}
impl JmapObject for FileNode {
type Property = FileNodeProperty;
type Element = FileNodeValue;
type Id = Id;
type Filter = FileNodeFilter;
type Comparator = FileNodeComparator;
type GetArguments = ();
type SetArguments<'de> = FileNodeSetArguments;
type QueryArguments = FileNodeQueryArguments;
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = FileNodeProperty::Id;
}
impl JmapSharedObject for FileNode {
type Right = FileNodeRight;
const SHARE_WITH_PROPERTY: Self::Property = FileNodeProperty::ShareWith;
}
impl From<Id> for FileNodeProperty {
fn from(id: Id) -> Self {
FileNodeProperty::IdValue(id)
}
}
impl JmapRight for FileNodeRight {
fn from_acl(acl: Acl) -> &'static [Self] {
match acl {
Acl::ReadItems => &[FileNodeRight::MayRead],
Acl::RemoveItems => &[FileNodeRight::MayDelete],
Acl::ModifyItems => &[FileNodeRight::MayWrite],
Acl::Delete => &[FileNodeRight::MayDelete],
Acl::Administer => &[FileNodeRight::MayShare],
_ => &[],
}
}
fn to_acl(&self) -> &'static [Acl] {
match self {
FileNodeRight::MayDelete => &[Acl::Delete, Acl::RemoveItems],
FileNodeRight::MayShare => &[Acl::Administer],
FileNodeRight::MayRead => &[Acl::Read, Acl::ReadItems],
FileNodeRight::MayWrite => &[Acl::Modify, Acl::AddItems, Acl::ModifyItems],
}
}
fn all_rights() -> &'static [Self] {
&[
FileNodeRight::MayRead,
FileNodeRight::MayWrite,
FileNodeRight::MayDelete,
FileNodeRight::MayShare,
]
}
}
impl From<FileNodeRight> for FileNodeProperty {
fn from(right: FileNodeRight) -> Self {
FileNodeProperty::Rights(right)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileNodeFilter {
HasParentId(bool),
ParentId(MaybeInvalid<Id>),
AncestorId(MaybeInvalid<Id>),
HasType(bool),
BlobId(MaybeInvalid<BlobId>),
IsExecutable(bool),
CreatedBefore(UTCDate),
CreatedAfter(UTCDate),
ModifiedBefore(UTCDate),
ModifiedAfter(UTCDate),
AccessedBefore(UTCDate),
AccessedAfter(UTCDate),
MinSize(u64),
MaxSize(u64),
Name(String),
NameMatch(GlobPattern),
Type(String),
TypeMatch(GlobPattern),
_T(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileNodeComparator {
Name,
Size,
Created,
Modified,
Type,
_T(String),
}
impl<'de> DeserializeArguments<'de> for FileNodeFilter {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
hashify::fnc_map!(key.as_bytes(),
b"hasParentId" => {
*self = FileNodeFilter::HasParentId(map.next_value()?);
},
b"parentId" => {
*self = FileNodeFilter::ParentId(map.next_value()?);
},
b"ancestorId" => {
*self = FileNodeFilter::AncestorId(map.next_value()?);
},
b"hasType" => {
*self = FileNodeFilter::HasType(map.next_value()?);
},
b"blobId" => {
*self = FileNodeFilter::BlobId(map.next_value()?);
},
b"isExecutable" => {
*self = FileNodeFilter::IsExecutable(map.next_value()?);
},
b"createdBefore" => {
*self = FileNodeFilter::CreatedBefore(map.next_value()?);
},
b"createdAfter" => {
*self = FileNodeFilter::CreatedAfter(map.next_value()?);
},
b"modifiedBefore" => {
*self = FileNodeFilter::ModifiedBefore(map.next_value()?);
},
b"modifiedAfter" => {
*self = FileNodeFilter::ModifiedAfter(map.next_value()?);
},
b"accessedBefore" => {
*self = FileNodeFilter::AccessedBefore(map.next_value()?);
},
b"accessedAfter" => {
*self = FileNodeFilter::AccessedAfter(map.next_value()?);
},
b"minSize" => {
*self = FileNodeFilter::MinSize(map.next_value()?);
},
b"maxSize" => {
*self = FileNodeFilter::MaxSize(map.next_value()?);
},
b"name" => {
*self = FileNodeFilter::Name(map.next_value()?);
},
b"nameMatch" => {
*self = FileNodeFilter::NameMatch(map.next_value()?);
},
b"type" => {
*self = FileNodeFilter::Type(map.next_value()?);
},
b"typeMatch" => {
*self = FileNodeFilter::TypeMatch(map.next_value()?);
},
_ => {
*self = FileNodeFilter::_T(key.to_string());
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
);
Ok(())
}
}
impl<'de> DeserializeArguments<'de> for FileNodeComparator {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "property" {
let value = map.next_value::<Cow<str>>()?;
hashify::fnc_map!(value.as_bytes(),
b"name" => {
*self = FileNodeComparator::Name;
},
b"size" => {
*self = FileNodeComparator::Size;
},
b"created" => {
*self = FileNodeComparator::Created;
},
b"modified" => {
*self = FileNodeComparator::Modified;
},
b"type" => {
*self = FileNodeComparator::Type;
},
_ => {
*self = FileNodeComparator::_T(key.to_string());
}
);
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl Default for FileNodeFilter {
fn default() -> Self {
FileNodeFilter::_T("".to_string())
}
}
impl Default for FileNodeComparator {
fn default() -> Self {
FileNodeComparator::_T("".to_string())
}
}
impl From<Id> for FileNodeValue {
fn from(id: Id) -> Self {
FileNodeValue::Id(id)
}
}
impl JmapObjectId for FileNodeValue {
fn as_id(&self) -> Option<Id> {
match self {
FileNodeValue::Id(id) => Some(*id),
_ => None,
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
FileNodeValue::Id(id) => Some(AnyId::Id(*id)),
FileNodeValue::BlobId(blob_id) => Some(AnyId::BlobId(blob_id.clone())),
_ => None,
}
}
fn as_id_ref(&self) -> Option<&str> {
if let FileNodeValue::IdReference(r) = self {
Some(r)
} else {
None
}
}
}
impl TryFrom<AnyId> for FileNodeValue {
type Error = ();
fn try_from(value: AnyId) -> Result<Self, Self::Error> {
match value {
AnyId::Id(id) => Ok(FileNodeValue::Id(id)),
AnyId::BlobId(blob_id) => Ok(FileNodeValue::BlobId(blob_id)),
}
}
}
impl FileNodeFilter {
pub fn into_string(self) -> Cow<'static, str> {
match self {
FileNodeFilter::HasParentId(_) => "hasParentId",
FileNodeFilter::ParentId(_) => "parentId",
FileNodeFilter::AncestorId(_) => "ancestorId",
FileNodeFilter::HasType(_) => "hasType",
FileNodeFilter::BlobId(_) => "blobId",
FileNodeFilter::IsExecutable(_) => "isExecutable",
FileNodeFilter::CreatedBefore(_) => "createdBefore",
FileNodeFilter::CreatedAfter(_) => "createdAfter",
FileNodeFilter::ModifiedBefore(_) => "modifiedBefore",
FileNodeFilter::ModifiedAfter(_) => "modifiedAfter",
FileNodeFilter::AccessedBefore(_) => "accessedBefore",
FileNodeFilter::AccessedAfter(_) => "accessedAfter",
FileNodeFilter::MinSize(_) => "minSize",
FileNodeFilter::MaxSize(_) => "maxSize",
FileNodeFilter::Name(_) => "name",
FileNodeFilter::NameMatch(_) => "nameMatch",
FileNodeFilter::Type(_) => "type",
FileNodeFilter::TypeMatch(_) => "typeMatch",
FileNodeFilter::_T(s) => return s.into(),
}
.into()
}
}
impl FileNodeComparator {
pub fn as_str(&self) -> &str {
match self {
FileNodeComparator::Name => "name",
FileNodeComparator::Size => "size",
FileNodeComparator::Created => "created",
FileNodeComparator::Modified => "modified",
FileNodeComparator::Type => "type",
FileNodeComparator::_T(s) => s.as_ref(),
}
}
pub fn into_string(self) -> Cow<'static, str> {
match self {
FileNodeComparator::Name => "name",
FileNodeComparator::Size => "size",
FileNodeComparator::Created => "created",
FileNodeComparator::Modified => "modified",
FileNodeComparator::Type => "type",
FileNodeComparator::_T(s) => return s.into(),
}
.into()
}
}
impl serde::Serialize for FileNodeComparator {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl TryFrom<FileNodeProperty> for Id {
type Error = ();
fn try_from(value: FileNodeProperty) -> Result<Self, Self::Error> {
if let FileNodeProperty::IdValue(id) = value {
Ok(id)
} else {
Err(())
}
}
}
impl TryFrom<FileNodeProperty> for FileNodeRight {
type Error = ();
fn try_from(value: FileNodeProperty) -> Result<Self, Self::Error> {
if let FileNodeProperty::Rights(right) = value {
Ok(right)
} else {
Err(())
}
}
}

View File

@@ -15,6 +15,7 @@ pub mod blob;
pub mod contact;
pub mod email;
pub mod email_submission;
pub mod file_node;
pub mod identity;
pub mod mailbox;
pub mod principal;

View File

@@ -63,6 +63,9 @@ impl Response<'_> {
GetResponseMethod::ContactCard(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::FileNode(response) => {
response.eval_jptr(path, &mut results)
}
},
ResponseMethod::Changes(response) => match response {
ChangesResponseMethod::Email(response) => {
@@ -89,6 +92,9 @@ impl Response<'_> {
ChangesResponseMethod::ContactCard(response) => {
response.eval_jptr(path, &mut results)
}
ChangesResponseMethod::FileNode(response) => {
response.eval_jptr(path, &mut results)
}
},
ResponseMethod::Query(response) => response.eval_jptr(path, &mut results),
ResponseMethod::QueryChanges(response) => {

View File

@@ -44,6 +44,7 @@ impl Response<'_> {
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
GetRequestMethod::AddressBook(request) => request.resolve_references(self)?,
GetRequestMethod::ContactCard(request) => request.resolve_references(self)?,
GetRequestMethod::FileNode(request) => request.resolve_references(self)?,
},
RequestMethod::Set(request) => match request {
SetRequestMethod::Email(request) => request.resolve_references(self)?,
@@ -55,6 +56,7 @@ impl Response<'_> {
SetRequestMethod::VacationResponse(request) => request.resolve_references(self)?,
SetRequestMethod::AddressBook(request) => request.resolve_references(self)?,
SetRequestMethod::ContactCard(request) => request.resolve_references(self)?,
SetRequestMethod::FileNode(request) => request.resolve_references(self)?,
},
RequestMethod::Copy(request) => match request {
CopyRequestMethod::Email(request) => request.resolve_references(self)?,

View File

@@ -6,7 +6,7 @@
use std::fmt;
use crate::response::serialize::serialize_hex;
use crate::{object::file_node::FileNodeComparator, response::serialize::serialize_hex};
use serde::{Deserialize, Deserializer};
use types::{id::Id, type_state::DataType};
use utils::map::vec_map::VecMap;
@@ -74,6 +74,8 @@ pub enum Capability {
Principals = 1 << 10,
#[serde(rename(serialize = "urn:ietf:params:jmap:principals:owner"))]
PrincipalsOwner = 1 << 11,
#[serde(rename(serialize = "urn:ietf:params:jmap:filenode"))]
FileNode = 1 << 12,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -94,6 +96,7 @@ pub enum Capabilities {
Contacts(ContactsCapabilities),
Principals(PrincipalsCapabilities),
PrincipalsOwner(PrincipalsOwnerCapabilities),
FileNode(FileNodeCapabilities),
Empty(EmptyCapabilities),
}
@@ -208,6 +211,18 @@ pub struct PrincipalsOwnerCapabilities {
pub principal_id: Id,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileNodeCapabilities {
#[serde(rename(serialize = "maxFileNodeDepth"))]
pub max_file_node_depth: Option<usize>,
#[serde(rename(serialize = "maxSizeFileNodeName"))]
pub max_size_file_node_name: usize,
#[serde(rename(serialize = "fileNodeQuerySortOptions"))]
pub file_node_query_sort_options: Vec<FileNodeComparator>,
#[serde(rename(serialize = "mayCreateTopLevelFileNode"))]
pub may_create_top_level_file_node: bool,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct EmptyCapabilities {}
@@ -363,7 +378,8 @@ impl Capability {
"urn:ietf:params:jmap:blob" => Capability::Blob,
"urn:ietf:params:jmap:quota" => Capability::Quota,
"urn:ietf:params:jmap:principals" => Capability::Principals,
"urn:ietf:params:jmap:principals:owner" => Capability::PrincipalsOwner
"urn:ietf:params:jmap:principals:owner" => Capability::PrincipalsOwner,
"urn:ietf:params:jmap:filenode" => Capability::FileNode
)
}
}

View File

@@ -29,6 +29,7 @@ pub enum MethodObject {
Quota,
AddressBook,
ContactCard,
FileNode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -136,6 +137,12 @@ impl MethodName {
(MethodFunction::Copy, MethodObject::ContactCard) => "ContactCard/copy",
(MethodFunction::Parse, MethodObject::ContactCard) => "ContactCard/parse",
(MethodFunction::Get, MethodObject::FileNode) => "FileNode/get",
(MethodFunction::Changes, MethodObject::FileNode) => "FileNode/changes",
(MethodFunction::Query, MethodObject::FileNode) => "FileNode/query",
(MethodFunction::QueryChanges, MethodObject::FileNode) => "FileNode/queryChanges",
(MethodFunction::Set, MethodObject::FileNode) => "FileNode/set",
(MethodFunction::Echo, MethodObject::Core) => "Core/echo",
_ => "error",
}
@@ -210,6 +217,12 @@ impl MethodName {
"ContactCard/copy" => (MethodObject::ContactCard, MethodFunction::Copy),
"ContactCard/parse" => (MethodObject::ContactCard, MethodFunction::Parse),
"FileNode/get" => (MethodObject::FileNode, MethodFunction::Get),
"FileNode/changes" => (MethodObject::FileNode, MethodFunction::Changes),
"FileNode/query" => (MethodObject::FileNode, MethodFunction::Query),
"FileNode/queryChanges" => (MethodObject::FileNode, MethodFunction::QueryChanges),
"FileNode/set" => (MethodObject::FileNode, MethodFunction::Set),
"Core/echo" => (MethodObject::Core, MethodFunction::Echo),
).map(|(obj, fnc)| MethodName { obj, fnc })
@@ -235,6 +248,7 @@ impl Display for MethodObject {
MethodObject::Quota => "Quota",
MethodObject::AddressBook => "AddressBook",
MethodObject::ContactCard => "ContactCard",
MethodObject::FileNode => "FileNode",
})
}
}

View File

@@ -29,9 +29,9 @@ use crate::{
},
object::{
AnyId, addressbook::AddressBook, blob::Blob, contact::ContactCard, email::Email,
email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox,
principal::Principal, push_subscription::PushSubscription, quota::Quota, sieve::Sieve,
thread::Thread, vacation_response::VacationResponse,
email_submission::EmailSubmission, file_node::FileNode, identity::Identity,
mailbox::Mailbox, principal::Principal, push_subscription::PushSubscription, quota::Quota,
sieve::Sieve, thread::Thread, vacation_response::VacationResponse,
},
request::{capability::CapabilityIds, reference::MaybeIdReference},
};
@@ -86,6 +86,7 @@ pub enum GetRequestMethod {
Blob(GetRequest<Blob>),
AddressBook(GetRequest<AddressBook>),
ContactCard(GetRequest<ContactCard>),
FileNode(GetRequest<FileNode>),
}
#[derive(Debug)]
@@ -99,6 +100,7 @@ pub enum SetRequestMethod<'x> {
VacationResponse(SetRequest<'x, VacationResponse>),
AddressBook(SetRequest<'x, AddressBook>),
ContactCard(SetRequest<'x, ContactCard>),
FileNode(SetRequest<'x, FileNode>),
}
#[derive(Debug)]
@@ -117,6 +119,7 @@ pub enum QueryRequestMethod {
Principal(QueryRequest<Principal>),
Quota(QueryRequest<Quota>),
ContactCard(QueryRequest<ContactCard>),
FileNode(QueryRequest<FileNode>),
}
#[derive(Debug)]
@@ -128,6 +131,7 @@ pub enum QueryChangesRequestMethod {
Principal(QueryChangesRequest<Principal>),
Quota(QueryChangesRequest<Quota>),
ContactCard(QueryChangesRequest<ContactCard>),
FileNode(QueryChangesRequest<FileNode>),
}
#[derive(Debug)]

View File

@@ -195,6 +195,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::FileNode) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::FileNode(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::SearchSnippet) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::SearchSnippet(value),
Err(err) => RequestMethod::invalid(err),
@@ -265,6 +272,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::FileNode) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::FileNode(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Query, MethodObject::Email) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Email(value)),
Err(err) => RequestMethod::invalid(err),
@@ -314,6 +328,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Query, MethodObject::FileNode) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::FileNode(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::QueryChanges, MethodObject::Email) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::QueryChanges(QueryChangesRequestMethod::Email(value))
@@ -379,6 +400,15 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::QueryChanges, MethodObject::FileNode) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::QueryChanges(QueryChangesRequestMethod::FileNode(value))
}
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Changes, _) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Changes(value),
Err(err) => RequestMethod::invalid(err),

View File

@@ -26,9 +26,9 @@ use crate::{
},
object::{
AnyId, addressbook::AddressBook, blob::Blob, contact::ContactCard, email::Email,
email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox,
principal::Principal, push_subscription::PushSubscription, quota::Quota, sieve::Sieve,
thread::Thread, vacation_response::VacationResponse,
email_submission::EmailSubmission, file_node::FileNode, identity::Identity,
mailbox::Mailbox, principal::Principal, push_subscription::PushSubscription, quota::Quota,
sieve::Sieve, thread::Thread, vacation_response::VacationResponse,
},
request::{Call, method::MethodName},
};
@@ -70,6 +70,7 @@ pub enum GetResponseMethod {
Blob(GetResponse<Blob>),
AddressBook(GetResponse<AddressBook>),
ContactCard(GetResponse<ContactCard>),
FileNode(GetResponse<FileNode>),
}
#[derive(Debug, serde::Serialize)]
@@ -84,6 +85,7 @@ pub enum SetResponseMethod {
VacationResponse(SetResponse<VacationResponse>),
AddressBook(SetResponse<AddressBook>),
ContactCard(SetResponse<ContactCard>),
FileNode(SetResponse<FileNode>),
}
#[derive(Debug, serde::Serialize)]
@@ -97,6 +99,7 @@ pub enum ChangesResponseMethod {
Quota(ChangesResponse<Quota>),
AddressBook(ChangesResponse<AddressBook>),
ContactCard(ChangesResponse<ContactCard>),
FileNode(ChangesResponse<FileNode>),
}
#[derive(Debug, serde::Serialize)]
@@ -435,3 +438,21 @@ impl<'x> From<MethodErrorWrapper> for ResponseMethod<'x> {
ResponseMethod::Error(value)
}
}
impl From<GetResponse<FileNode>> for ResponseMethod<'_> {
fn from(response: GetResponse<FileNode>) -> Self {
ResponseMethod::Get(GetResponseMethod::FileNode(response))
}
}
impl From<SetResponse<FileNode>> for ResponseMethod<'_> {
fn from(response: SetResponse<FileNode>) -> Self {
ResponseMethod::Set(SetResponseMethod::FileNode(response))
}
}
impl From<ChangesResponse<FileNode>> for ResponseMethod<'_> {
fn from(response: ChangesResponse<FileNode>) -> Self {
ResponseMethod::Changes(ChangesResponseMethod::FileNode(response))
}
}

View File

@@ -150,7 +150,7 @@ impl AddressBookGet for Server {
}
AddressBookProperty::MyRights => {
result.insert_unchecked(
AddressBookProperty::IsDefault,
AddressBookProperty::MyRights,
if access_token.is_shared(account_id) {
JmapRights::rights::<addressbook::AddressBook>(
address_book.acls.effective_acl(access_token),

View File

@@ -69,6 +69,7 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
GetRequestMethod::AddressBook(_) => Permission::JmapAddressBookGet,
GetRequestMethod::ContactCard(_) => Permission::JmapContactCardGet,
GetRequestMethod::FileNode(_) => Permission::JmapFileNodeGet,
},
RequestMethod::Set(m) => match &m {
SetRequestMethod::Email(_) => Permission::JmapEmailSet,
@@ -80,6 +81,7 @@ impl JmapAuthorization for AccessToken {
SetRequestMethod::VacationResponse(_) => Permission::JmapVacationResponseSet,
SetRequestMethod::AddressBook(_) => Permission::JmapAddressBookSet,
SetRequestMethod::ContactCard(_) => Permission::JmapContactCardSet,
SetRequestMethod::FileNode(_) => Permission::JmapFileNodeSet,
},
RequestMethod::Changes(_) => match object {
MethodObject::Email => Permission::JmapEmailChanges,
@@ -89,6 +91,7 @@ impl JmapAuthorization for AccessToken {
MethodObject::EmailSubmission => Permission::JmapEmailSubmissionChanges,
MethodObject::Quota => Permission::JmapQuotaChanges,
MethodObject::ContactCard => Permission::JmapContactCardChanges,
MethodObject::FileNode => Permission::JmapFileNodeChanges,
MethodObject::Core
| MethodObject::Blob
| MethodObject::PushSubscription
@@ -120,6 +123,7 @@ impl JmapAuthorization for AccessToken {
QueryChangesRequestMethod::ContactCard(_) => {
Permission::JmapContactCardQueryChanges
}
QueryChangesRequestMethod::FileNode(_) => Permission::JmapFileNodeQueryChanges,
},
RequestMethod::Query(m) => match m {
QueryRequestMethod::Email(_) => Permission::JmapEmailQuery,
@@ -129,6 +133,7 @@ impl JmapAuthorization for AccessToken {
QueryRequestMethod::Principal(_) => Permission::JmapPrincipalQuery,
QueryRequestMethod::Quota(_) => Permission::JmapQuotaQuery,
QueryRequestMethod::ContactCard(_) => Permission::JmapContactCardQuery,
QueryRequestMethod::FileNode(_) => Permission::JmapFileNodeQuery,
},
RequestMethod::SearchSnippet(_) => Permission::JmapSearchSnippet,
RequestMethod::ValidateScript(_) => Permission::JmapSieveScriptValidate,

View File

@@ -17,6 +17,7 @@ use crate::{
copy::JmapEmailCopy, get::EmailGet, import::EmailImport, parse::EmailParse,
query::EmailQuery, set::EmailSet, snippet::EmailSearchSnippet,
},
file::{get::FileNodeGet, query::FileNodeQuery, set::FileNodeSet},
identity::{get::IdentityGet, set::IdentitySet},
mailbox::{get::MailboxGet, query::MailboxQuery, set::MailboxSet},
principal::{get::PrincipalGet, query::PrincipalQuery},
@@ -135,6 +136,9 @@ impl RequestHandler for Server {
SetResponseMethod::ContactCard(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::FileNode(set_response) => {
set_response.update_created_ids(&mut response);
}
}
}
ResponseMethod::ImportEmail(import_response) => {
@@ -269,6 +273,12 @@ impl RequestHandler for Server {
self.contact_card_get(req, access_token).await?.into()
}
GetRequestMethod::FileNode(mut req) => {
set_account_id_if_missing(&mut req.account_id, access_token);
access_token.assert_has_access(req.account_id, Collection::FileNode)?;
self.file_node_get(req, access_token).await?.into()
}
},
RequestMethod::Query(req) => match req {
QueryRequestMethod::Email(mut req) => {
@@ -311,6 +321,12 @@ impl RequestHandler for Server {
self.contact_card_query(req, access_token).await?.into()
}
QueryRequestMethod::FileNode(mut req) => {
set_account_id_if_missing(&mut req.account_id, access_token);
access_token.assert_has_access(req.account_id, Collection::FileNode)?;
self.file_node_query(req, access_token).await?.into()
}
},
RequestMethod::Set(req) => match req {
SetRequestMethod::Email(mut req) => {
@@ -373,6 +389,12 @@ impl RequestHandler for Server {
.await?
.into()
}
SetRequestMethod::FileNode(mut req) => {
set_account_id_if_missing(&mut req.account_id, access_token);
access_token.assert_has_access(req.account_id, Collection::FileNode)?;
self.file_node_set(req, access_token, session).await?.into()
}
},
RequestMethod::Changes(mut req) => {
set_account_id_if_missing(&mut req.account_id, access_token);

View File

@@ -65,6 +65,16 @@ impl ChangesLookup for Server {
(SyncCollection::EmailSubmission, false)
}
MethodObject::ContactCard => {
access_token.assert_has_access(request.account_id, Collection::AddressBook)?;
(SyncCollection::AddressBook, false)
}
MethodObject::FileNode => {
access_token.assert_has_access(request.account_id, Collection::FileNode)?;
(SyncCollection::FileNode, true)
}
_ => {
access_token.assert_is_member(request.account_id)?;
@@ -233,6 +243,9 @@ impl IntermediateChangesResponse {
MethodObject::ContactCard => {
ChangesResponseMethod::ContactCard(transmute_response(self.response))
}
MethodObject::FileNode => {
ChangesResponseMethod::FileNode(transmute_response(self.response))
}
MethodObject::Core
| MethodObject::Blob
| MethodObject::PushSubscription

View File

@@ -7,8 +7,8 @@
use super::get::ChangesLookup;
use crate::{
api::request::set_account_id_if_missing, contact::query::ContactCardQuery,
email::query::EmailQuery, mailbox::query::MailboxQuery, sieve::query::SieveScriptQuery,
submission::query::EmailSubmissionQuery,
email::query::EmailQuery, file::query::FileNodeQuery, mailbox::query::MailboxQuery,
sieve::query::SieveScriptQuery, submission::query::EmailSubmissionQuery,
};
use common::{Server, auth::AccessToken};
use jmap_proto::{
@@ -161,6 +161,28 @@ impl QueryChanges for Server {
.contact_card_query(request.into(), access_token)
.await?;
}
QueryChangesRequestMethod::FileNode(mut request) => {
// Query changes
set_account_id_if_missing(&mut request.account_id, access_token);
changes = self
.changes(
build_changes_request(&request),
MethodObject::FileNode,
access_token,
)
.await?
.response;
let calculate_total = request.calculate_total.unwrap_or(false);
has_changes = changes.has_changes();
response = build_query_changes_response(&request, &changes);
if !has_changes && !calculate_total {
return Ok(response);
}
up_to_id = request.up_to_id;
results = self.file_node_query(request.into(), access_token).await?;
}
QueryChangesRequestMethod::Principal(_) => {
return Err(trc::JmapEvent::CannotCalculateChanges.into_err());
}

View File

@@ -108,7 +108,7 @@ impl ContactCardQuery for Server {
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
for comparator in request
.sort
.and_then(|s| if !s.is_empty() { s.into() } else { None })
.filter(|s| !s.is_empty())
.unwrap_or_else(|| vec![Comparator::descending(ContactCardComparator::Updated)])
{
comparators.push(match comparator.property {

View File

@@ -333,7 +333,7 @@ impl EmailQuery for Server {
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
for comparator in request
.sort
.and_then(|s| if !s.is_empty() { s.into() } else { None })
.filter(|s| !s.is_empty())
.unwrap_or_else(|| vec![Comparator::descending(EmailComparator::ReceivedAt)])
{
comparators.push(match comparator.property {

221
crates/jmap/src/file/get.rs Normal file
View File

@@ -0,0 +1,221 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
use groupware::{cache::GroupwareCache, file::FileNode};
use jmap_proto::{
method::get::{GetRequest, GetResponse},
object::file_node::{self, FileNodeProperty, FileNodeValue},
types::date::UTCDate,
};
use jmap_tools::{Map, Value};
use store::{roaring::RoaringBitmap, write::now};
use trc::AddContext;
use types::{
acl::{Acl, AclGrant},
blob::{BlobClass, BlobId},
blob_hash::BlobHash,
collection::{Collection, SyncCollection},
};
pub trait FileNodeGet: Sync + Send {
fn file_node_get(
&self,
request: GetRequest<file_node::FileNode>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<GetResponse<file_node::FileNode>>> + Send;
}
impl FileNodeGet for Server {
async fn file_node_get(
&self,
mut request: GetRequest<file_node::FileNode>,
access_token: &AccessToken,
) -> trc::Result<GetResponse<file_node::FileNode>> {
let ids = request.unwrap_ids(self.core.jmap.get_max_objects)?;
let properties = request.unwrap_properties(&[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ParentId,
FileNodeProperty::Size,
]);
let account_id = request.account_id.document_id();
let cache = self
.fetch_dav_resources(access_token, account_id, SyncCollection::FileNode)
.await?;
let file_node_ids = if access_token.is_member(account_id) {
cache.document_ids(true).collect::<RoaringBitmap>()
} else {
cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true)
};
let ids = if let Some(ids) = ids {
ids
} else {
file_node_ids
.iter()
.take(self.core.jmap.get_max_objects)
.map(Into::into)
.collect::<Vec<_>>()
};
let mut response = GetResponse {
account_id: request.account_id.into(),
state: cache.get_state(true).into(),
list: Vec::with_capacity(ids.len()),
not_found: vec![],
};
for id in ids {
// Obtain the file_node object
let document_id = id.document_id();
if !file_node_ids.contains(document_id) {
response.not_found.push(id);
continue;
}
let _file_node = if let Some(file_node) = self
.get_archive(account_id, Collection::FileNode, document_id)
.await?
{
file_node
} else {
response.not_found.push(id);
continue;
};
let file_node = _file_node
.unarchive::<FileNode>()
.caused_by(trc::location!())?;
let mut result = Map::with_capacity(properties.len());
for property in &properties {
match property {
FileNodeProperty::Id => {
result.insert_unchecked(FileNodeProperty::Id, FileNodeValue::Id(id));
}
FileNodeProperty::Name => {
result.insert_unchecked(FileNodeProperty::Name, file_node.name.to_string());
}
FileNodeProperty::ShareWith => {
result.insert_unchecked(
FileNodeProperty::ShareWith,
JmapRights::share_with::<file_node::FileNode>(
account_id,
access_token,
&file_node
.acls
.iter()
.map(AclGrant::from)
.collect::<Vec<_>>(),
),
);
}
FileNodeProperty::MyRights => {
result.insert_unchecked(
FileNodeProperty::MyRights,
if access_token.is_shared(account_id) {
JmapRights::rights::<file_node::FileNode>(
file_node.acls.effective_acl(access_token),
)
} else {
JmapRights::all_rights::<file_node::FileNode>()
},
);
}
FileNodeProperty::ParentId => {
let parent_id = file_node.parent_id.to_native();
result.insert_unchecked(
FileNodeProperty::ParentId,
if parent_id > 0 {
Value::Element(FileNodeValue::Id((parent_id - 1).into()))
} else {
Value::Null
},
);
}
FileNodeProperty::BlobId => {
result.insert_unchecked(
FileNodeProperty::BlobId,
if let Some(file) = file_node.file.as_ref() {
Value::Element(FileNodeValue::BlobId(BlobId::new(
BlobHash::from(&file.blob_hash),
BlobClass::Linked {
account_id,
collection: Collection::FileNode.into(),
document_id: id.document_id(),
},
)))
} else {
Value::Null
},
);
}
FileNodeProperty::Size => {
result.insert_unchecked(
FileNodeProperty::Size,
if let Some(file) = file_node.file.as_ref() {
Value::Number(file.size.to_native().into())
} else {
Value::Null
},
);
}
FileNodeProperty::Type => {
result.insert_unchecked(
FileNodeProperty::Type,
if let Some(file) =
file_node.file.as_ref().and_then(|f| f.media_type.as_ref())
{
Value::Str(file.to_string().into())
} else {
Value::Null
},
);
}
FileNodeProperty::Executable => {
result.insert_unchecked(
FileNodeProperty::Executable,
if let Some(file) = file_node.file.as_ref() {
Value::Bool(file.executable)
} else {
Value::Null
},
);
}
FileNodeProperty::Created => {
result.insert_unchecked(
FileNodeProperty::Created,
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
file_node.created.to_native(),
))),
);
}
FileNodeProperty::Modified => {
result.insert_unchecked(
FileNodeProperty::Modified,
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
file_node.modified.to_native(),
))),
);
}
FileNodeProperty::Accessed => {
result.insert_unchecked(
FileNodeProperty::Accessed,
Value::Element(FileNodeValue::Date(UTCDate::from_timestamp(
now() as i64
))),
);
}
property => {
result.insert_unchecked(property.clone(), Value::Null);
}
}
}
response.list.push(result.into());
}
Ok(response)
}
}

View File

@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod get;
pub mod query;
pub mod set;

View File

@@ -0,0 +1,180 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{JmapMethods, changes::state::JmapCacheState};
use common::{Server, auth::AccessToken};
use groupware::cache::GroupwareCache;
use jmap_proto::{
method::query::{Filter, QueryRequest, QueryResponse},
object::file_node::{FileNode, FileNodeFilter},
request::MaybeInvalid,
};
use store::{query, roaring::RoaringBitmap};
use types::{
acl::Acl,
collection::{Collection, SyncCollection},
};
pub trait FileNodeQuery: Sync + Send {
fn file_node_query(
&self,
request: QueryRequest<FileNode>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
}
impl FileNodeQuery for Server {
async fn file_node_query(
&self,
mut request: QueryRequest<FileNode>,
access_token: &AccessToken,
) -> trc::Result<QueryResponse> {
let account_id = request.account_id.document_id();
let mut filters = Vec::with_capacity(request.filter.len());
let cache = self
.fetch_dav_resources(access_token, account_id, SyncCollection::FileNode)
.await?;
let filter_mask = (access_token.is_shared(account_id))
.then(|| cache.shared_items(access_token, [Acl::ReadItems], true));
for cond in std::mem::take(&mut request.filter) {
match cond {
Filter::Property(cond) => match cond {
FileNodeFilter::AncestorId(MaybeInvalid::Value(id)) => {
if let Some(resource) =
cache.container_resource_path_by_id(id.document_id())
{
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.subtree(resource.path()).map(|r| r.document_id()),
)))
} else {
filters.push(query::Filter::is_in_set(RoaringBitmap::new()));
}
}
FileNodeFilter::ParentId(MaybeInvalid::Value(id)) => {
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.children_ids(id.document_id()),
)));
}
FileNodeFilter::HasParentId(has_parent_id) => {
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.resources.iter().filter_map(|r| {
if has_parent_id == r.parent_id().is_some() {
Some(r.document_id)
} else {
None
}
}),
)));
}
FileNodeFilter::Name(name) => {
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.resources.iter().filter_map(|r| {
if r.container_name().is_some_and(|n| n == name) {
Some(r.document_id)
} else {
None
}
}),
)));
}
FileNodeFilter::NameMatch(name) => {
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.resources.iter().filter_map(|r| {
if r.container_name().is_some_and(|n| name.matches(n)) {
Some(r.document_id)
} else {
None
}
}),
)));
}
FileNodeFilter::MinSize(size) => {
let size = size as u32;
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.resources.iter().filter_map(|r| {
if r.size().is_some_and(|s| s >= size) {
Some(r.document_id)
} else {
None
}
}),
)));
}
FileNodeFilter::MaxSize(size) => {
let size = size as u32;
filters.push(query::Filter::is_in_set(RoaringBitmap::from_iter(
cache.resources.iter().filter_map(|r| {
if r.size().is_some_and(|s| s <= size) {
Some(r.document_id)
} else {
None
}
}),
)));
}
unsupported => {
return Err(trc::JmapEvent::UnsupportedFilter
.into_err()
.details(unsupported.into_string()));
}
},
Filter::And | Filter::Or | Filter::Not | Filter::Close => {
filters.push(cond.into());
}
}
}
let mut result_set = self
.filter(account_id, Collection::FileNode, filters)
.await?;
if let Some(filter_mask) = filter_mask {
result_set.apply_mask(filter_mask);
}
let (response, paginate) = self
.build_query_response(&result_set, cache.get_state(false), &request)
.await?;
if let Some(paginate) = paginate {
// Parse sort criteria
/*let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
for comparator in request
.sort
.filter(|s| !s.is_empty())
.unwrap_or_else(|| vec![Comparator::descending(FileNodeComparator::Updated)])
{
comparators.push(match comparator.property {
FileNodeComparator::Created => {
query::Comparator::field(ContactField::Created, comparator.is_ascending)
}
FileNodeComparator::Updated => {
query::Comparator::field(ContactField::Updated, comparator.is_ascending)
}
unsupported => {
return Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details(unsupported.into_string()));
}
});
}*/
if request.sort.is_some_and(|s| !s.is_empty()) {
return Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details("Sorting is not supported on FileNode"));
}
// Sort results
self.sort(result_set, Default::default(), paginate, response)
.await
} else {
Ok(response)
}
}
}

425
crates/jmap/src/file/set.rs Normal file
View File

@@ -0,0 +1,425 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::api::acl::{JmapAcl, JmapRights};
use common::{DavResources, Server, auth::AccessToken, sharing::EffectiveAcl};
use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode};
use http_proto::HttpSessionData;
use jmap_proto::{
error::set::SetError,
method::set::{SetRequest, SetResponse},
object::file_node::{self, FileNodeProperty, FileNodeValue},
references::resolve::ResolveCreatedReference,
request::IntoValid,
types::state::State,
};
use jmap_tools::{JsonPointerItem, Key, Value};
use store::{ahash::AHashSet, write::BatchBuilder};
use trc::AddContext;
use types::{
acl::{Acl, AclGrant},
collection::{Collection, SyncCollection},
id::Id,
};
pub trait FileNodeSet: Sync + Send {
fn file_node_set(
&self,
request: SetRequest<'_, file_node::FileNode>,
access_token: &AccessToken,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<SetResponse<file_node::FileNode>>> + Send;
}
impl FileNodeSet for Server {
async fn file_node_set(
&self,
mut request: SetRequest<'_, file_node::FileNode>,
access_token: &AccessToken,
_session: &HttpSessionData,
) -> trc::Result<SetResponse<file_node::FileNode>> {
let account_id = request.account_id.document_id();
let cache = self
.fetch_dav_resources(access_token, account_id, SyncCollection::FileNode)
.await?;
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
let will_destroy = request.unwrap_destroy().into_valid().collect::<Vec<_>>();
let is_shared = access_token.is_shared(account_id);
// Process creates
let mut batch = BatchBuilder::new();
'create: for (id, object) in request.unwrap_create() {
if is_shared {
response.not_created.append(
id,
SetError::forbidden()
.with_description("Cannot create file nodes in a shared account."),
);
continue 'create;
}
let mut file_node = FileNode::default();
// Process changes
if let Err(err) = update_file_node(object, &mut file_node, &mut response) {
response.not_created.append(id, err);
continue 'create;
}
// Validate hierarchy
if let Err(err) =
validate_file_node_hierarchy(None, file_node.parent_id, is_shared, &cache)
{
response.not_created.append(id, err);
continue 'create;
}
// Validate ACLs
if !file_node.acls.is_empty() {
if let Err(err) = self.acl_validate(&file_node.acls).await {
response.not_created.append(id, err.into());
continue 'create;
}
self.refresh_acls(&file_node.acls, None).await;
}
// Insert record
let document_id = self
.store()
.assign_document_ids(account_id, Collection::FileNode, 1)
.await
.caused_by(trc::location!())?;
file_node
.insert(access_token, account_id, document_id, &mut batch)
.caused_by(trc::location!())?;
response.created(id, document_id);
}
// Process updates
'update: for (id, object) in request.unwrap_update().into_valid() {
// Make sure id won't be destroyed
if will_destroy.contains(&id) {
response.not_updated.append(id, SetError::will_destroy());
continue 'update;
}
// Obtain file node
let document_id = id.document_id();
let file_node_ = if let Some(file_node_) = self
.get_archive(account_id, Collection::FileNode, document_id)
.await?
{
file_node_
} else {
response.not_updated.append(id, SetError::not_found());
continue 'update;
};
let file_node = file_node_
.to_unarchived::<FileNode>()
.caused_by(trc::location!())?;
let mut new_file_node = file_node
.deserialize::<FileNode>()
.caused_by(trc::location!())?;
// Apply changes
let has_acl_changes = match update_file_node(object, &mut new_file_node, &mut response)
{
Ok(has_acl_changes_) => has_acl_changes_,
Err(err) => {
response.not_updated.append(id, err);
continue 'update;
}
};
// Validate hierarchy
if new_file_node.parent_id != file_node.inner.parent_id
&& let Err(err) = validate_file_node_hierarchy(
Some(document_id),
new_file_node.parent_id,
is_shared,
&cache,
)
{
response.not_updated.append(id, err);
continue 'update;
}
// Validate ACL
if is_shared {
let acl = file_node.inner.acls.effective_acl(access_token);
if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Administer))
{
response.not_updated.append(
id,
SetError::forbidden()
.with_description("You are not allowed to modify this file node."),
);
continue 'update;
}
}
if has_acl_changes {
if let Err(err) = self.acl_validate(&new_file_node.acls).await {
response.not_updated.append(id, err.into());
continue 'update;
}
self.refresh_acls(
&new_file_node.acls,
Some(
file_node
.inner
.acls
.iter()
.map(AclGrant::from)
.collect::<Vec<_>>()
.as_slice(),
),
)
.await;
}
// Update record
new_file_node
.update(access_token, file_node, account_id, document_id, &mut batch)
.caused_by(trc::location!())?;
response.updated.append(id, None);
}
// Process deletions
let on_destroy_remove_children = request
.arguments
.on_destroy_remove_children
.unwrap_or(false);
let mut destroy_ids = AHashSet::with_capacity(will_destroy.len());
for id in will_destroy {
let document_id = id.document_id();
let Some(file_node) = cache.container_resource_path_by_id(document_id) else {
response.not_destroyed.append(id, SetError::not_found());
continue;
};
// Find ids to delete
let mut ids = cache.subtree(file_node.path()).collect::<Vec<_>>();
if ids.is_empty() {
debug_assert!(false, "Resource found in cache but not in subtree");
continue;
}
// Sort ids descending from the deepest to the root
ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq()));
let mut sorted_ids = Vec::with_capacity(ids.len());
sorted_ids.extend(ids.into_iter().map(|a| a.document_id()));
// Validate not already deleted
for child_id in &sorted_ids {
if !destroy_ids.insert(*child_id) {
response.not_destroyed.append(
id,
SetError::will_destroy().with_description(
"File node or one of its children is already marked for deletion.",
),
);
continue;
}
}
// Validate ACLs
if !access_token.is_member(account_id) {
let permissions = cache.shared_containers(access_token, [Acl::Delete], false);
if permissions.len() < sorted_ids.len() as u64
|| !sorted_ids.iter().all(|id| permissions.contains(*id))
{
response.not_destroyed.append(
id,
SetError::forbidden()
.with_description("You are not allowed to delete this file node."),
);
continue;
}
}
// Obtain children ids
if sorted_ids.len() > 1 && !on_destroy_remove_children {
response
.not_destroyed
.append(id, SetError::node_has_children());
continue;
}
// Delete record
response
.destroyed
.extend(sorted_ids.iter().copied().map(Id::from));
DestroyArchive(sorted_ids)
.delete_batch(
self,
access_token,
account_id,
cache.format_resource(file_node).into(),
&mut batch,
)
.await?;
}
// Write changes
if !batch.is_empty() {
let change_id = self
.commit_batch(batch)
.await
.and_then(|ids| ids.last_change_id(account_id))
.caused_by(trc::location!())?;
response.new_state = State::Exact(change_id).into();
}
Ok(response)
}
}
fn update_file_node(
updates: Value<'_, FileNodeProperty, FileNodeValue>,
file_node: &mut FileNode,
response: &mut SetResponse<file_node::FileNode>,
) -> Result<bool, SetError<FileNodeProperty>> {
let mut has_acl_changes = false;
for (property, mut value) in updates.into_expanded_object() {
let Key::Property(property) = property else {
return Err(SetError::invalid_properties()
.with_property(property.to_owned())
.with_description("Invalid property."));
};
response.resolve_self_references(&mut value)?;
match (property, value) {
(FileNodeProperty::Name, Value::Str(value))
if (1..=255).contains(&value.len())
&& !value.contains('/')
&& ![".", ".."].contains(&value.as_ref()) =>
{
file_node.name = value.into_owned();
}
(FileNodeProperty::ParentId, Value::Element(FileNodeValue::Id(value))) => {
file_node.parent_id = value.document_id() + 1;
}
(FileNodeProperty::ParentId, Value::Null) => {
file_node.parent_id = 0;
}
(FileNodeProperty::BlobId, Value::Element(FileNodeValue::BlobId(value))) => {
file_node.file.get_or_insert_default().blob_hash = value.hash;
}
(FileNodeProperty::BlobId, Value::Null) => {}
(FileNodeProperty::Size, Value::Number(value)) => {
file_node.file.get_or_insert_default().size = value.cast_to_u64() as u32;
}
(FileNodeProperty::Type, Value::Str(value)) if (1..=30).contains(&value.len()) => {
file_node.file.get_or_insert_default().media_type = value.into_owned().into();
}
(FileNodeProperty::Type, Value::Null) => {
file_node.file.get_or_insert_default().media_type = None;
}
(FileNodeProperty::Executable, Value::Bool(value)) => {
file_node.file.get_or_insert_default().executable = value;
}
(FileNodeProperty::Executable, Value::Null) => {
file_node.file.get_or_insert_default().executable = false;
}
(FileNodeProperty::Created, Value::Element(FileNodeValue::Date(value))) => {
file_node.created = value.timestamp();
}
(FileNodeProperty::Modified, Value::Element(FileNodeValue::Date(value))) => {
file_node.modified = value.timestamp();
}
(FileNodeProperty::ShareWith, value) => {
file_node.acls = JmapRights::acl_set::<file_node::FileNode>(value)?;
has_acl_changes = true;
}
(FileNodeProperty::Pointer(pointer), value)
if matches!(
pointer.first(),
Some(JsonPointerItem::Key(Key::Property(
FileNodeProperty::ShareWith
)))
) =>
{
let mut pointer = pointer.iter();
pointer.next();
file_node.acls = JmapRights::acl_patch::<file_node::FileNode>(
std::mem::take(&mut file_node.acls),
pointer,
value,
)?;
has_acl_changes = true;
}
(property, _) => {
return Err(SetError::invalid_properties()
.with_property(property.clone())
.with_description("Field could not be set."));
}
}
}
// Validate name
if file_node.name.is_empty() {
return Err(SetError::invalid_properties()
.with_property(FileNodeProperty::Name)
.with_description("Missing name."));
}
// Validate blob hash
if file_node
.file
.as_ref()
.is_some_and(|f| f.blob_hash.is_empty())
{
return Err(SetError::invalid_properties()
.with_property(FileNodeProperty::BlobId)
.with_description("Missing blob id."));
}
Ok(has_acl_changes)
}
fn validate_file_node_hierarchy(
document_id: Option<u32>,
parent_id: u32,
is_shared: bool,
cache: &DavResources,
) -> Result<(), SetError<FileNodeProperty>> {
if parent_id == 0 {
if is_shared {
return Err(SetError::invalid_properties()
.with_property(FileNodeProperty::ParentId)
.with_description("Cannot create top-level folder in a shared account."));
}
} else {
let parent_id = parent_id - 1;
if let Some(document_id) = document_id {
if document_id == parent_id {
return Err(SetError::invalid_properties()
.with_property(FileNodeProperty::ParentId)
.with_description("A file node cannot be its own parent."));
}
if let Some(file) = cache.container_resource_path_by_id(document_id)
&& cache
.subtree(file.path())
.any(|r| r.document_id() == parent_id)
{
return Err(SetError::invalid_properties()
.with_property(FileNodeProperty::ParentId)
.with_description("Circular reference in parent ids."));
}
}
}
Ok(())
}

View File

@@ -30,6 +30,7 @@ pub mod blob;
pub mod changes;
pub mod contact;
pub mod email;
pub mod file;
pub mod identity;
pub mod mailbox;
pub mod principal;

View File

@@ -71,7 +71,7 @@ impl SieveScriptQuery for Server {
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
for comparator in request
.sort
.and_then(|s| if !s.is_empty() { s.into() } else { None })
.filter(|s| !s.is_empty())
.unwrap_or_else(|| vec![Comparator::descending(SieveComparator::Name)])
{
comparators.push(match comparator.property {

View File

@@ -120,7 +120,7 @@ impl EmailSubmissionQuery for Server {
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
for comparator in request
.sort
.and_then(|s| if !s.is_empty() { s.into() } else { None })
.filter(|s| !s.is_empty())
.unwrap_or_else(|| vec![Comparator::descending(EmailSubmissionComparator::SentAt)])
{
comparators.push(match comparator.property {

View File

@@ -49,6 +49,10 @@ impl BlobHash {
}
hex
}
pub fn is_empty(&self) -> bool {
self.0 == [0; BLOB_HASH_LEN]
}
}
impl From<&ArchivedBlobHash> for BlobHash {

View File

@@ -47,11 +47,6 @@ pub enum EmailField {
To,
Cc,
Bcc,
//ReplyTo,
//Sender,
//InReplyTo,
//MessageId,
//EmailIds,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -127,11 +122,6 @@ impl From<EmailField> for u8 {
EmailField::SentAt => 26,
EmailField::HasAttachment => 89,
EmailField::Archive => ARCHIVE_FIELD,
//EmailField::MessageId => 11,
//EmailField::ReplyTo => 21,
//EmailField::Sender => 25,
//EmailField::EmailIds => 84,
//EmailField::InReplyTo => 96,
}
}
}

View File

@@ -4,7 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::borrow::Cow;
use ahash::{AHashMap, AHashSet};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlobPattern {
@@ -207,3 +210,15 @@ impl<V> Default for GlobMap<V> {
GlobMap::new()
}
}
impl<'de> Deserialize<'de> for GlobPattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(GlobPattern::compile(
<Cow<&str>>::deserialize(deserializer)?.as_ref(),
true,
))
}
}