Everything clients, users and operators meet now carries the fork's name, with no aliases (SPEC.md §2.4, changed here from "protocol identifiers stay"): - JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap. - WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once. - Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in; a unit test fails if Cargo.lock ever moves past the vendored copy. The trusted runtime now names itself too, rather than answering sieve-rs's default. - The web interface's OAuth client is inbuxa-webui. On every start the old stalwart-webui client is removed and any application naming it is moved over. - The spam filter's blobs are INBUXA_SPAM_*; every start moves any left under the old keys, so a trained model survives. - SQL stores and log files default to inbuxa, in the code and in the schema served to the admin (checksum regenerated). - Settings are INBUXA_* only. A STALWART_* variable that's set where its INBUXA_* one isn't stops the server at startup, naming it. - The version-upgrade messages link docs.inbuxa.org's migration page, and the OpenAPI description, smtp crate metadata and web-push test fixtures lose the name. Kept on purpose, allowlisted with reasons: the OAuth key-derivation contexts (renaming them would end every session and invalidate every sealed client id) and the hashed application prefix. Also fixes a latent start-up failure: ensure_client updated an existing first-party client with a revision of 0, which the registry's assertion never matches, so adding a redirect URI or changing the webmail secret failed start-up. And the principal session test now expects legacyProtocols (C-1, added 2026-09-21), which it had missed. Tested: the server builds without warnings; common's 106 unit tests, including the vendoring check; a new integration test for the two start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
239 lines
6.9 KiB
Rust
239 lines
6.9 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
use crate::{DavError, DavResourceName};
|
|
use common::{Server, auth::AccessToken};
|
|
use groupware::cache::GroupwareCache;
|
|
use http_proto::request::decode_path_element;
|
|
use hyper::StatusCode;
|
|
use std::fmt::Display;
|
|
use trc::AddContext;
|
|
use types::collection::Collection;
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct UriResource<A, R> {
|
|
pub collection: Collection,
|
|
pub account_id: A,
|
|
pub resource: R,
|
|
}
|
|
|
|
pub(crate) enum Urn {
|
|
Lock(u64),
|
|
Sync { id: u64, seq: u32 },
|
|
}
|
|
|
|
pub(crate) type UnresolvedUri<'x> = UriResource<Option<u32>, Option<&'x str>>;
|
|
pub(crate) type OwnedUri<'x> = UriResource<u32, Option<&'x str>>;
|
|
pub(crate) type DocumentUri = UriResource<u32, u32>;
|
|
|
|
pub(crate) trait DavUriResource: Sync + Send {
|
|
fn validate_uri_with_status<'x>(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
uri: &'x str,
|
|
error_status: StatusCode,
|
|
) -> impl Future<Output = crate::Result<UnresolvedUri<'x>>> + Send;
|
|
|
|
fn validate_uri<'x>(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
uri: &'x str,
|
|
) -> impl Future<Output = crate::Result<UnresolvedUri<'x>>> + Send;
|
|
|
|
fn map_uri_resource(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
uri: OwnedUri<'_>,
|
|
) -> impl Future<Output = trc::Result<Option<DocumentUri>>> + Send;
|
|
}
|
|
|
|
impl DavUriResource for Server {
|
|
async fn validate_uri<'x>(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
uri: &'x str,
|
|
) -> crate::Result<UnresolvedUri<'x>> {
|
|
self.validate_uri_with_status(access_token, uri, StatusCode::NOT_FOUND)
|
|
.await
|
|
}
|
|
|
|
async fn validate_uri_with_status<'x>(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
uri: &'x str,
|
|
error_status: StatusCode,
|
|
) -> crate::Result<UnresolvedUri<'x>> {
|
|
let (_, uri_parts) = uri
|
|
.split_once("/dav/")
|
|
.ok_or(DavError::Code(error_status))?;
|
|
|
|
let mut uri_parts = uri_parts
|
|
.trim_end_matches('/')
|
|
.splitn(3, '/')
|
|
.filter(|x| !x.is_empty());
|
|
let mut resource = UriResource {
|
|
collection: uri_parts
|
|
.next()
|
|
.and_then(DavResourceName::parse)
|
|
.ok_or(DavError::Code(error_status))?
|
|
.into(),
|
|
account_id: None,
|
|
resource: None,
|
|
};
|
|
if let Some(account) = uri_parts.next() {
|
|
// Parse account id
|
|
let account_id = if let Some(account_id) = account.strip_prefix('_') {
|
|
account_id
|
|
.parse::<u32>()
|
|
.map_err(|_| DavError::Code(error_status))?
|
|
} else {
|
|
let account = decode_path_element(account);
|
|
self.account_id_from_email(&account, false)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.ok_or(DavError::Code(error_status))?
|
|
};
|
|
|
|
// Validate access
|
|
if resource.collection != Collection::Principal
|
|
&& !access_token.has_access(account_id, resource.collection)
|
|
{
|
|
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
|
}
|
|
|
|
// Obtain remaining path
|
|
resource.account_id = Some(account_id);
|
|
resource.resource = uri_parts.next();
|
|
}
|
|
|
|
Ok(resource)
|
|
}
|
|
|
|
async fn map_uri_resource(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
uri: OwnedUri<'_>,
|
|
) -> trc::Result<Option<DocumentUri>> {
|
|
if let Some(resource) = uri.resource {
|
|
if let Some(resource) = self
|
|
.fetch_dav_resources(
|
|
access_token.account_id(),
|
|
uri.account_id,
|
|
uri.collection.into(),
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.by_path(resource)
|
|
{
|
|
Ok(Some(DocumentUri {
|
|
collection: if resource.is_container() {
|
|
uri.collection
|
|
} else {
|
|
uri.collection.child_collection().unwrap_or(uri.collection)
|
|
},
|
|
account_id: uri.account_id,
|
|
resource: resource.document_id(),
|
|
}))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'x> UnresolvedUri<'x> {
|
|
pub fn into_owned_uri(self) -> crate::Result<OwnedUri<'x>> {
|
|
Ok(OwnedUri {
|
|
collection: self.collection,
|
|
account_id: self
|
|
.account_id
|
|
.ok_or(DavError::Code(StatusCode::FORBIDDEN))?,
|
|
resource: self.resource,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl OwnedUri<'_> {
|
|
pub fn new_owned(
|
|
collection: Collection,
|
|
account_id: u32,
|
|
resource: Option<&str>,
|
|
) -> OwnedUri<'_> {
|
|
OwnedUri {
|
|
collection,
|
|
account_id,
|
|
resource,
|
|
}
|
|
}
|
|
}
|
|
|
|
/*impl<A, R> UriResource<A, R> {
|
|
pub fn collection_path(&self) -> &'static str {
|
|
DavResourceName::from(self.collection).collection_path()
|
|
}
|
|
}*/
|
|
|
|
impl Urn {
|
|
pub fn try_extract_sync_id(token: &str) -> Option<&str> {
|
|
token
|
|
.strip_prefix("urn:inbuxa:davsync:")
|
|
.map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x))
|
|
}
|
|
|
|
pub fn parse(input: &str) -> Option<Self> {
|
|
let inbox = input.strip_prefix("urn:inbuxa:")?;
|
|
let (kind, id) = inbox.split_once(':')?;
|
|
match kind {
|
|
"davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock),
|
|
"davsync" => {
|
|
if let Some((id, seq)) = id.split_once(':') {
|
|
let id = u64::from_str_radix(id, 16).ok()?;
|
|
let seq = u32::from_str_radix(seq, 16).ok()?;
|
|
Some(Urn::Sync { id, seq })
|
|
} else {
|
|
u64::from_str_radix(id, 16)
|
|
.ok()
|
|
.map(|id| Urn::Sync { id, seq: 0 })
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn try_unwrap_lock(&self) -> Option<u64> {
|
|
match self {
|
|
Urn::Lock(id) => Some(*id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn try_unwrap_sync(&self) -> Option<(u64, u32)> {
|
|
match self {
|
|
Urn::Sync { id, seq } => Some((*id, *seq)),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for Urn {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Urn::Lock(id) => write!(f, "urn:inbuxa:davlock:{id:x}",),
|
|
Urn::Sync { id, seq } => {
|
|
if *seq == 0 {
|
|
write!(f, "urn:inbuxa:davsync:{id:x}")
|
|
} else {
|
|
write!(f, "urn:inbuxa:davsync:{id:x}:{seq:x}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|