/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use crate::{blob::download::BlobDownload, changes::state::StateManager}; use common::{ Server, auth::{AccessToken, AccountCache}, storage::index::ObjectIndexBuilder, }; use email::sieve::{ ArchivedSieveScript, SieveScript, delete::SieveScriptDelete, ingest::SieveScriptIngest, }; use http_proto::HttpSessionData; use jmap_proto::{ error::set::{SetError, SetErrorType}, method::set::{SetRequest, SetResponse}, object::sieve::{Sieve, SieveProperty, SieveValue}, references::resolve::ResolveCreatedReference, request::{MaybeInvalid, reference::MaybeIdReference}, types::state::State, }; use jmap_tools::{Key, Map, Value}; use rand::distr::Alphanumeric; use registry::schema::enums::StorageQuota; use sieve::compiler::ErrorType; use std::future::Future; use store::{ Serialize, SerializeInfallible, ValueKey, rand::{RngExt, rng}, write::{AlignedBytes, Archive, Archiver, BatchBuilder}, }; use trc::AddContext; use types::{ blob::{BlobClass, BlobId, BlobSection}, collection::{Collection, SyncCollection}, field::{PrincipalField, SieveField}, id::Id, }; pub struct SetContext<'x> { account_id: u32, access_token: &'x AccessToken, account_cache: &'x AccountCache, response: SetResponse, } pub trait SieveScriptSet: Sync + Send { fn sieve_script_set( &self, request: SetRequest<'_, Sieve>, access_token: &AccessToken, session: &HttpSessionData, ) -> impl Future>> + Send; #[allow(clippy::type_complexity)] fn sieve_set_item<'x>( &self, changes_: Value<'_, SieveProperty, SieveValue>, update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, ctx: &SetContext, session_id: u64, ) -> impl Future, SetError>>> + Send; } impl SieveScriptSet for Server { async fn sieve_script_set( &self, mut request: SetRequest<'_, Sieve>, access_token: &AccessToken, session: &HttpSessionData, ) -> trc::Result> { let account_id = request.account_id.document_id(); let sieve_ids = self .document_ids(account_id, Collection::SieveScript, SieveField::Name) .await?; let account = self.account(account_id).await.caused_by(trc::location!())?; let mut ctx = SetContext { account_id, access_token, account_cache: &account, response: SetResponse::from_request(&request, self.core.jmap.set_max_objects)? .with_state( self.assert_state( account_id, SyncCollection::SieveScript, &request.if_in_state, ) .await?, ), }; let will_destroy = ctx.response.collect_will_destroy(request.unwrap_destroy()); // Validate active script id if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_activate_script && !sieve_ids.contains(id.document_id()) { request.arguments.on_success_activate_script = None; } // Process creates let mut batch = BatchBuilder::new(); let mut activations = Vec::new(); for (id, object) in request.unwrap_create() { if sieve_ids.len() < self.object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) as u64 { match self .sieve_set_item(object, None, &ctx, session.session_id) .await? { Ok(mut result) => { // Store blob let sieve = &mut result.builder.changes_mut().unwrap(); let (blob_hash, blob_hold) = self .put_temporary_blob( account_id, result.blob_update.as_ref().unwrap(), 60, ) .await?; sieve.blob_hash = blob_hash; let blob_size = sieve.size as usize; let blob_hash = sieve.blob_hash.clone(); // Write record let document_id = self .store() .assign_document_ids(account_id, Collection::SieveScript, 1) .await .caused_by(trc::location!())?; batch .with_account_id(account_id) .with_collection(Collection::SieveScript) .with_document(document_id) .custom( result .builder .with_changed_by(ctx.access_token.account_tenant_ids()), ) .caused_by(trc::location!())? .clear(blob_hold) .commit_point(); // Set isActive if needed if let Some(set_item) = result.set_item { activations.push((document_id, set_item)); } let mut result = Map::with_capacity(1) .with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into())) .with_key_value( SieveProperty::BlobId, SieveValue::BlobId(BlobId { hash: blob_hash, class: BlobClass::Linked { account_id, collection: Collection::SieveScript.into(), document_id, }, section: BlobSection { size: blob_size, ..Default::default() } .into(), }), ); // Update active script if needed if let Some(MaybeIdReference::Reference(id_ref)) = &request.arguments.on_success_activate_script && id_ref == &id { request.arguments.on_success_activate_script = Some(MaybeIdReference::Id(Id::from(document_id))); result.insert_unchecked(SieveProperty::IsActive, true); } // Add result with updated blobId ctx.response.created.insert(id, result.into()); } Err(err) => { ctx.response.not_created.append(id, err); } } } else { ctx.response.not_created.append( id, SetError::new(SetErrorType::OverQuota).with_description(concat!( "There are too many sieve scripts, ", "please delete some before adding a new one." )), ); } } // Process updates 'update: for (id, object) in request.unwrap_update() { let id = match id { MaybeInvalid::Value(id) => id, invalid => { ctx.response .not_updated .append(invalid, SetError::not_found()); continue 'update; } }; // Make sure id won't be destroyed if will_destroy.contains(&id) { ctx.response .not_updated .append(id, SetError::will_destroy()); continue 'update; } // Obtain sieve script let document_id = id.document_id(); if let Some(sieve_) = self .store() .get_value::>(ValueKey::archive( account_id, Collection::SieveScript, document_id, )) .await? { let sieve = sieve_ .to_unarchived::() .caused_by(trc::location!())?; match self .sieve_set_item( object, (document_id, sieve).into(), &ctx, session.session_id, ) .await? { Ok(mut result) => { // Prepare write batch batch .with_account_id(account_id) .with_collection(Collection::SieveScript) .with_document(document_id); let blob_id = if let Some(blob) = result.blob_update.take() { // Store blob let sieve = &mut result.builder.changes_mut().unwrap(); let (blob_hash, blob_hold) = self.put_temporary_blob(account_id, &blob, 60).await?; sieve.blob_hash = blob_hash; batch.clear(blob_hold); BlobId { hash: sieve.blob_hash.clone(), class: BlobClass::Linked { account_id, collection: Collection::SieveScript.into(), document_id, }, section: BlobSection { size: sieve.size as usize, ..Default::default() } .into(), } .into() } else { None }; // Set isActive if needed if let Some(set_item) = result.set_item { activations.push((document_id, set_item)); } // Write record batch .custom( result .builder .with_changed_by(ctx.access_token.account_tenant_ids()), ) .caused_by(trc::location!())? .commit_point(); // Update blobId property if needed let mut result = Map::with_capacity(1); if let Some(blob_id) = blob_id { result.insert_unchecked( SieveProperty::BlobId, SieveValue::BlobId(blob_id), ); } // Add active script property if needed if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_activate_script && document_id == id.document_id() { result.insert_unchecked(SieveProperty::IsActive, true); } // Add result ctx.response.updated.append( id, if !result.is_empty() { Value::Object(result).into() } else { None }, ); } Err(err) => { ctx.response.not_updated.append(id, err); continue 'update; } } } else { ctx.response.not_updated.append(id, SetError::not_found()); } } // Process deletions let active_script_id = self.sieve_script_get_active_id(account_id).await?; for id in will_destroy { let document_id = id.document_id(); if sieve_ids.contains(document_id) { if active_script_id != Some(document_id) { if self .sieve_script_delete(account_id, document_id, ctx.access_token, &mut batch) .await? { ctx.response.destroyed.push(id); } else { ctx.response.not_destroyed.append(id, SetError::not_found()); } } else { ctx.response.not_destroyed.append( id, SetError::new(SetErrorType::ScriptIsActive) .with_description("Deactivate Sieve script before deletion."), ); } } else { ctx.response.not_destroyed.append(id, SetError::not_found()); } } // Non-standard script activation handling let mut on_success_deactivate_script = request .arguments .on_success_deactivate_script .unwrap_or(false); if activations.len() == 1 { let (document_id, set_item) = activations[0]; let is_active = active_script_id.is_some_and(|active_id| active_id == document_id); if set_item { if request.arguments.on_success_activate_script.is_none() && !is_active { request.arguments.on_success_activate_script = Some(MaybeIdReference::Id(document_id.into())); } } else if !on_success_deactivate_script && is_active { on_success_deactivate_script = true; } } // Activate / deactivate scripts if ctx.response.not_created.is_empty() && ctx.response.not_updated.is_empty() && ctx.response.not_destroyed.is_empty() && (request.arguments.on_success_activate_script.is_some() || on_success_deactivate_script) { if let Some(MaybeIdReference::Id(id)) = request.arguments.on_success_activate_script { batch .with_account_id(account_id) .with_collection(Collection::Principal) .with_document(0) .set(PrincipalField::ActiveScriptId, id.document_id().serialize()); } else if on_success_deactivate_script { batch .with_account_id(account_id) .with_collection(Collection::Principal) .with_document(0) .clear(PrincipalField::ActiveScriptId); } } // Write changes if !batch.is_empty() && let Ok(change_id) = self .commit_batch(batch) .await .caused_by(trc::location!())? .last_change_id(account_id) { ctx.response.new_state = State::Exact(change_id).into(); } Ok(ctx.response) } #[allow(clippy::blocks_in_conditions)] async fn sieve_set_item<'x>( &self, changes_: Value<'_, SieveProperty, SieveValue>, update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, ctx: &SetContext<'_>, session_id: u64, ) -> trc::Result, SetError>> { // Vacation script cannot be modified if update .as_ref() .is_some_and(|(_, obj)| obj.inner.name.eq_ignore_ascii_case("vacation")) { return Ok(Err(SetError::forbidden().with_description(concat!( "The 'vacation' script cannot be modified, ", "use VacationResponse/set instead." )))); } // Parse properties let mut set_item = None; let mut changes = update .as_ref() .map(|(_, obj)| obj.deserialize().unwrap_or_default()) .unwrap_or_default(); let mut blob_id = None; for (property, mut value) in changes_.into_expanded_object() { if let Err(err) = ctx.response.resolve_self_references(&mut value, 0, false) { return Ok(Err(err)); }; match (&property, value) { (Key::Property(SieveProperty::Name), Value::Str(value)) => { if value.len() > self.core.email.sieve_max_script_name { return Ok(Err(SetError::invalid_properties() .with_property(property.into_owned()) .with_description("Script name is too long."))); } else if value.eq_ignore_ascii_case("vacation") { return Ok(Err(SetError::forbidden() .with_property(property.into_owned()) .with_description( "The 'vacation' name is reserved, please use a different name.", ))); } else if update .as_ref() .is_none_or(|(_, obj)| obj.inner.name != value.as_ref()) && let Some(id) = self .document_ids_matching( ctx.account_id, Collection::SieveScript, SieveField::Name, value.as_bytes(), ) .await? .min() { return Ok(Err(SetError::already_exists() .with_existing_id(id.into()) .with_description(format!( "A sieve script with name '{}' already exists.", value )))); } changes.name = value.into_owned(); } ( Key::Property(SieveProperty::BlobId), Value::Element(SieveValue::BlobId(value)), ) => { blob_id = value.into(); continue; } (Key::Property(SieveProperty::Name), Value::Null) => { continue; } (Key::Property(SieveProperty::IsActive), Value::Bool(value)) => { set_item = Some(value); continue; } (Key::Property(SieveProperty::Id), value) => { if update .as_ref() .map(|(document_id, _)| Id::from(*document_id)) .is_none_or(|expected| !crate::matches_id(&value, expected)) { return Ok(Err(SetError::invalid_properties() .with_property(SieveProperty::Id) .with_description("The id property is immutable.".to_string()))); } } _ => { return Ok(Err(SetError::invalid_properties() .with_property(property.into_owned()) .with_description("Invalid property or value.".to_string()))); } } } if update.is_none() { // Add name if missing if changes.name.is_empty() { changes.name = rng() .sample_iter(Alphanumeric) .take(15) .map(char::from) .collect::(); } } let blob_update = if let Some(blob_id) = blob_id { if update.as_ref().is_none_or( |(document_id, _)| { !matches!(blob_id.class, BlobClass::Linked { account_id, collection, document_id: d } if account_id == ctx.account_id && collection == u8::from(Collection::SieveScript) && *document_id == d) }) { // Check access if let Some(mut bytes) = self.blob_download(&blob_id, ctx.access_token).await? { // Check quota match self .has_available_quota(ctx.account_cache, bytes.len() as u64) .await { Ok(_) => (), Err(err) => { if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) || err.matches(trc::EventType::Limit(trc::LimitEvent::TenantQuota)) { trc::error!(err.account_id(ctx.account_id).span_id(session_id)); return Ok(Err(SetError::over_quota())); } else { return Err(err); } } } // Compile script match self.core.sieve.untrusted_compiler.compile(&bytes) { Ok(script) => { changes.size = bytes.len() as u32; bytes.extend(Archiver::new(script).untrusted().serialize().caused_by(trc::location!())?); bytes.into() } Err(err) => { return Ok(Err(SetError::new( if let ErrorType::ScriptTooLong = &err.error_type() { SetErrorType::TooLarge } else { SetErrorType::InvalidScript }, ) .with_description(err.to_string()))); } } } else { return Ok(Err(SetError::new(SetErrorType::BlobNotFound) .with_property(SieveProperty::BlobId) .with_description("Blob does not exist."))); } } else { None } } else if update.is_none() { return Ok(Err(SetError::invalid_properties() .with_property(SieveProperty::BlobId) .with_description("Missing blobId."))); } else { None }; // Validate Ok(Ok(SetItemResponse { builder: ObjectIndexBuilder::new() .with_changes(changes) .with_current_opt(update.map(|(_, current)| current)), blob_update, set_item, })) } } pub struct SetItemResponse<'x> { builder: ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>, blob_update: Option>, set_item: Option, }