Internal directory implementation + Management REST API

This commit is contained in:
mdecimus
2023-12-16 10:35:15 +01:00
parent f21bce722e
commit 232d4d691a
64 changed files with 1933 additions and 621 deletions

View File

@@ -0,0 +1,303 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use directory::{
backend::internal::{manage::ManageDirectory, PrincipalUpdate},
Directory, DirectoryError, ManagementError, Principal, QueryBy, Type,
};
use http_body_util::combinators::BoxBody;
use hyper::{body::Bytes, Method, StatusCode};
use jmap_proto::error::request::RequestError;
use serde_json::json;
use crate::JMAP;
use super::{http::ToHttpResponse, HttpRequest, JsonResponse};
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct PrincipalResponse {
pub id: u32,
#[serde(rename = "type")]
pub typ: Type,
pub quota: u32,
#[serde(rename = "usedQuota")]
pub used_quota: u32,
pub name: String,
pub emails: Vec<String>,
#[serde(rename = "memberOf")]
pub member_of: Vec<String>,
pub description: Option<String>,
}
impl JMAP {
pub async fn handle_manage_request(
&self,
req: &HttpRequest,
body: Option<Vec<u8>>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
let mut path = req.uri().path().split('/');
path.next();
path.next();
match (path.next().unwrap_or(""), path.next(), req.method()) {
("principal", None, &Method::POST) => {
// Create principal
if let Some(principal) =
body.and_then(|body| serde_json::from_slice::<Principal<String>>(&body).ok())
{
match self.store.create_account(principal).await {
Ok(account_id) => JsonResponse::new(json!({
"accountId": account_id,
"status": "success",
}))
.into_http_response(),
Err(err) => map_directory_error(err),
}
} else {
RequestError::blank(
StatusCode::BAD_REQUEST.as_u16(),
"Invalid parameters",
"Failed to deserialize principal object",
)
.into_http_response()
}
}
("principal", None, &Method::GET) => {
// List principal ids
let mut from_key = None;
let mut limit: usize = 0;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"limit" => {
limit = value.parse().unwrap_or_default();
}
"from" => {
from_key = value.into();
}
_ => {}
}
}
}
match self.store.list_accounts(from_key.as_deref(), limit).await {
Ok(accounts) => JsonResponse::new(json!({
"status": "success",
"data": accounts,
}))
.into_http_response(),
Err(err) => map_directory_error(err),
}
}
("principal", Some(name), method) => {
// Fetch, update or delete principal
let account_id = match self.store.get_account_id(name).await {
Ok(Some(account_id)) => account_id,
Ok(None) => {
return RequestError::blank(
StatusCode::NOT_FOUND.as_u16(),
"Not found",
"Account not found.",
)
.into_http_response();
}
Err(err) => {
return map_directory_error(err);
}
};
match *method {
Method::GET => {
let result = match self.store.query(QueryBy::Id(account_id)).await {
Ok(Some(principal)) => self.store.map_group_ids(principal).await,
Ok(None) => {
return RequestError::blank(
StatusCode::NOT_FOUND.as_u16(),
"Not found",
"Account not found.",
)
.into_http_response()
}
Err(err) => Err(err),
};
match result {
Ok(principal) => {
// Obtain quota usage
let mut principal = PrincipalResponse::from(principal);
principal.used_quota =
self.get_used_quota(account_id).await.unwrap_or_default()
as u32;
JsonResponse::new(json!({
"status": "success",
"data": principal,
}))
.into_http_response()
}
Err(err) => map_directory_error(err),
}
}
Method::DELETE => {
// Remove FTS index
if let Err(err) = self.fts_store.remove_all(account_id).await {
tracing::warn!(
context = "fts",
event = "error",
reason = ?err,
"Failed to remove FTS index"
);
return RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Failed to remove FTS index",
"Contact the administrator if this problem persists",
)
.into_http_response();
}
// Delete account
match self.store.delete_account(QueryBy::Id(account_id)).await {
Ok(_) => JsonResponse::new(json!({
"status": "success",
}))
.into_http_response(),
Err(err) => map_directory_error(err),
}
}
Method::PUT => {
if let Some(changes) = body.and_then(|body| {
serde_json::from_slice::<Vec<PrincipalUpdate>>(&body).ok()
}) {
match self
.store
.update_account(QueryBy::Id(account_id), changes)
.await
{
Ok(account_id) => JsonResponse::new(json!({
"accountId": account_id,
"status": "success",
}))
.into_http_response(),
Err(err) => map_directory_error(err),
}
} else {
RequestError::blank(
StatusCode::BAD_REQUEST.as_u16(),
"Invalid parameters",
"Failed to deserialize modify request",
)
.into_http_response()
}
}
_ => RequestError::not_found().into_http_response(),
}
}
("store", Some("purge"), &Method::GET) => {
match self.store.purge_blobs(self.blob_store.clone()).await {
Ok(_) => match self.store.purge_bitmaps().await {
Ok(_) => JsonResponse::new(json!({
"status": "success",
}))
.into_http_response(),
Err(err) => RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Purge database failed",
err.to_string(),
)
.into_http_response(),
},
Err(err) => RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Purge blob failed",
err.to_string(),
)
.into_http_response(),
}
}
(path_1 @ ("queue" | "report"), Some(path_2), &Method::GET) => {
self.smtp
.handle_manage_request(req.uri(), req.method(), path_1, path_2)
.await
}
_ => RequestError::not_found().into_http_response(),
}
}
}
fn map_directory_error(err: DirectoryError) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
match err {
DirectoryError::Management(err) => {
let response = match err {
ManagementError::MissingField(details) => json!({
"status": "missingField",
"details": details,
}),
ManagementError::NotUniqueField(details) => json!({
"status": "notUniqueField",
"details": details,
}),
ManagementError::NotFound(details) => json!({
"status": "notFound",
"details": details,
}),
};
JsonResponse::new(response).into_http_response()
}
DirectoryError::Unsupported => JsonResponse::new(json!({
"status": "unsupported",
"details": "Requested action is unsupported",
}))
.into_http_response(),
err => {
tracing::warn!(
context = "directory",
event = "error",
reason = ?err,
"Directory error"
);
RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Database error",
"Contact the administrator if this problem persists",
)
.into_http_response()
}
}
}
impl From<Principal<String>> for PrincipalResponse {
fn from(principal: Principal<String>) -> Self {
PrincipalResponse {
id: principal.id,
typ: principal.typ,
quota: principal.quota,
name: principal.name,
emails: principal.emails,
member_of: principal.member_of,
description: principal.description,
used_quota: 0,
}
}
}

View File

@@ -38,7 +38,6 @@ use jmap_proto::{
response::Response,
types::{blob::BlobId, id::Id},
};
use serde_json::Value;
use tokio::{
io::{AsyncRead, AsyncWrite},
net::TcpStream,
@@ -274,138 +273,17 @@ pub async fn parse_jmap_request(
_ => (),
}
}
"admin" => {
// Make sure the user is a superuser
match jmap.authenticate_headers(&req, remote_ip).await {
Ok(Some((_, access_token))) if access_token.is_super_user() => (),
let body = match jmap.authenticate_headers(&req, remote_ip).await {
Ok(Some((_, access_token))) if access_token.is_super_user() => {
fetch_body(&mut req, 8192, &access_token).await
}
Ok(_) => return RequestError::unauthorized().into_http_response(),
Err(err) => return err.into_http_response(),
}
};
match (
path.next().unwrap_or(""),
path.next().unwrap_or(""),
req.method(),
) {
("account", "delete", &Method::GET) => {
let todo = true;
/*
// Remove FTS index
self.fts_store.remove_all(principal.id).await?;
*/
todo!()
/*return if let Some(account_name) = path.next() {
if let Ok(Some(account_id)) = jmap.try_get_account_id(account_name).await {
match jmap.delete_account(account_name, account_id).await {
Ok(_) => JsonResponse::new(Value::String("success".into()))
.into_http_response(),
Err(err) => RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Account deletion failed",
err.to_string(),
)
.into_http_response(),
}
} else {
RequestError::blank(
StatusCode::NOT_FOUND.as_u16(),
"Not found",
"Account not found.",
)
.into_http_response()
}
} else {
RequestError::blank(
StatusCode::BAD_REQUEST.as_u16(),
"Invalid parameters",
"Expected account name",
)
.into_http_response()
};*/
}
("account", "rename", &Method::GET) => {
todo!()
/*return if let (Some(account_name), Some(new_account_name)) =
(path.next(), path.next())
{
match (
jmap.try_get_account_id(account_name).await,
jmap.try_get_account_id(new_account_name).await,
) {
(Ok(Some(account_id)), Ok(None)) => {
match jmap
.rename_account(new_account_name, account_name, account_id)
.await
{
Ok(_) => JsonResponse::new(Value::String("success".into()))
.into_http_response(),
Err(err) => RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Account rename failed",
err.to_string(),
)
.into_http_response(),
}
}
(Ok(None), _) => RequestError::blank(
StatusCode::NOT_FOUND.as_u16(),
"Not found",
"Account not found.",
)
.into_http_response(),
(_, Ok(Some(_))) => RequestError::blank(
StatusCode::BAD_REQUEST.as_u16(),
"Invalid parameters",
"New account name already exists.",
)
.into_http_response(),
_ => RequestError::internal_server_error().into_http_response(),
}
} else {
RequestError::blank(
StatusCode::BAD_REQUEST.as_u16(),
"Invalid parameters",
"Expected old and new account names",
)
.into_http_response()
};*/
}
("blob", "purge", &Method::GET) => {
return match jmap.store.purge_blobs(jmap.blob_store.clone()).await {
Ok(_) => {
JsonResponse::new(Value::String("success".into())).into_http_response()
}
Err(err) => RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Purge blob failed",
err.to_string(),
)
.into_http_response(),
};
}
("db", "purge", &Method::GET) => {
return match jmap.store.purge_bitmaps().await {
Ok(_) => {
JsonResponse::new(Value::String("success".into())).into_http_response()
}
Err(err) => RequestError::blank(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"Purge database failed",
err.to_string(),
)
.into_http_response(),
};
}
(path_1 @ ("queue" | "report"), path_2, &Method::GET) => {
return jmap
.smtp
.handle_manage_request(req.uri(), req.method(), path_1, path_2)
.await;
}
_ => (),
}
return jmap.handle_manage_request(&req, body).await;
}
_ => (),
}

View File

@@ -30,6 +30,7 @@ use utils::map::vec_map::VecMap;
use crate::JMAP;
pub mod admin;
pub mod config;
pub mod event_source;
pub mod http;

View File

@@ -214,7 +214,7 @@ impl JMAP {
session.add_account(
(*id).into(),
self.directory
.query(QueryBy::id(*id).with_store(&self.store))
.query(QueryBy::Id(*id))
.await
.unwrap_or_default()
.map(|p| p.name)

View File

@@ -379,7 +379,7 @@ impl JMAP {
{
if let Some(principal) = self
.directory
.query(QueryBy::id(id.document_id()).with_store(&self.store))
.query(QueryBy::Id(id.document_id()))
.await
.unwrap_or_default()
{
@@ -452,11 +452,7 @@ impl JMAP {
async fn map_acl_accounts(&self, mut acl_set: Vec<Value>) -> Result<Vec<Value>, SetError> {
for item in &mut acl_set {
if let Value::Text(account_name) = item {
match self
.directory
.query(QueryBy::name(account_name).with_store(&self.store))
.await
{
match self.directory.query(QueryBy::Name(account_name)).await {
Ok(Some(principal)) => {
*item = Value::Id(principal.id.into());
}

View File

@@ -174,13 +174,10 @@ impl JMAP {
) -> Option<AccessToken> {
match self
.directory
.query(
QueryBy::credentials(&Credentials::Plain {
username: username.to_string(),
secret: secret.to_string(),
})
.with_store(&self.store),
)
.query(QueryBy::Credentials(&Credentials::Plain {
username: username.to_string(),
secret: secret.to_string(),
}))
.await
{
Ok(Some(mut principal)) => {
@@ -201,10 +198,7 @@ impl JMAP {
pub async fn get_access_token(&self, account_id: u32) -> Option<AccessToken> {
// Create access token
self.update_access_token(AccessToken::new(
self.directory
.query(QueryBy::id(account_id).with_store(&self.store))
.await
.ok()??,
self.directory.query(QueryBy::Id(account_id)).await.ok()??,
))
.await
}

View File

@@ -56,7 +56,7 @@ pub struct AccessToken {
}
impl AccessToken {
pub fn new(principal: Principal) -> Self {
pub fn new(principal: Principal<u32>) -> Self {
Self {
primary_id: principal.id,
member_of: principal.member_of,

View File

@@ -182,7 +182,7 @@ impl JMAP {
) -> Result<TokenResponse, &'static str> {
let password_hash = self
.directory
.query(QueryBy::id(account_id).with_store(&self.store))
.query(QueryBy::Id(account_id))
.await
.map_err(|_| "Temporary lookup error")?
.ok_or("Account no longer exists")?
@@ -301,7 +301,7 @@ impl JMAP {
let password_hash = self
.directory
.query(QueryBy::id(account_id).with_store(&self.store))
.query(QueryBy::Id(account_id))
.await
.map_err(|_| "Temporary lookup error")?
.ok_or("Account no longer exists")?

View File

@@ -75,7 +75,7 @@ impl JMAP {
if let Value::Text(email) = identity.get(&Property::Email) {
if !self
.directory
.query(QueryBy::id(account_id).with_store(&self.store))
.query(QueryBy::Id(account_id))
.await
.unwrap_or_default()
.unwrap_or_default()

View File

@@ -599,7 +599,7 @@ impl JMAP {
access_token.quota as i64
} else {
self.directory
.query(QueryBy::id(account_id).with_store(&self.store))
.query(QueryBy::Id(account_id))
.await
.map_err(|err| {
tracing::error!(

View File

@@ -70,7 +70,7 @@ impl JMAP {
// Obtain the principal
let principal = if let Some(principal) = self
.directory
.query(QueryBy::id(id.document_id()).with_store(&self.store))
.query(QueryBy::Id(id.document_id()))
.await
.map_err(|_| MethodError::ServerPartialFail)?
{

View File

@@ -49,7 +49,7 @@ impl JMAP {
Filter::Name(name) => {
if let Some(principal) = self
.directory
.query(QueryBy::name(name.as_str()).with_store(&self.store))
.query(QueryBy::Name(name.as_str()))
.await
.map_err(|_| MethodError::ServerPartialFail)?
{
@@ -68,7 +68,7 @@ impl JMAP {
let mut ids = RoaringBitmap::new();
for id in self
.directory
.email_to_ids(&email, &self.store)
.email_to_ids(&email)
.await
.map_err(|_| MethodError::ServerPartialFail)?
{

View File

@@ -47,11 +47,7 @@ impl JMAP {
let mut recipients = Vec::with_capacity(message.recipients.len());
let mut deliver_names = AHashMap::with_capacity(message.recipients.len());
for rcpt in &message.recipients {
let uids = self
.directory
.email_to_ids(rcpt, &self.store)
.await
.unwrap_or_default();
let uids = self.directory.email_to_ids(rcpt).await.unwrap_or_default();
for uid in &uids {
deliver_names.insert(*uid, (DeliveryResult::Success, rcpt));
}
@@ -73,11 +69,7 @@ impl JMAP {
.await
}
Ok(None) => {
let account_quota = match self
.directory
.query(QueryBy::id(*uid).with_store(&self.store))
.await
{
let account_quota = match self.directory.query(QueryBy::Id(*uid)).await {
Ok(Some(p)) => p.quota as i64,
Ok(None) => 0,
Err(_) => {

View File

@@ -78,11 +78,7 @@ impl JMAP {
let mut instance = self.sieve_runtime.filter_parsed(message);
// Set account name and obtain quota
let (account_quota, mail_from) = match self
.directory
.query(QueryBy::id(account_id).with_store(&self.store))
.await
{
let (account_quota, mail_from) = match self.directory.query(QueryBy::Id(account_id)).await {
Ok(Some(p)) => {
instance.set_user_full_name(p.description().unwrap_or_else(|| p.name()));
(p.quota as i64, p.emails.into_iter().next())