Updated REST principal API

This commit is contained in:
mdecimus 2024-02-18 16:48:44 +01:00
parent afe10e6d81
commit 8027f135bc
13 changed files with 224 additions and 150 deletions

View file

@ -61,7 +61,7 @@ impl AccountCommands {
..Default::default()
};
let account_id = client
.http_request::<u32, _>(Method::POST, "/admin/principal", Some(principal))
.http_request::<u32, _>(Method::POST, "/api/principal", Some(principal))
.await;
eprintln!("Successfully created account {name:?} with id {account_id}.");
}
@ -131,7 +131,7 @@ impl AccountCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(changes),
)
.await;
@ -144,7 +144,7 @@ impl AccountCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
addresses
.into_iter()
@ -164,7 +164,7 @@ impl AccountCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
addresses
.into_iter()
@ -184,7 +184,7 @@ impl AccountCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
member_of
.into_iter()
@ -204,7 +204,7 @@ impl AccountCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
member_of
.into_iter()
@ -224,7 +224,7 @@ impl AccountCommands {
client
.http_request::<Value, String>(
Method::DELETE,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
None,
)
.await;
@ -233,9 +233,13 @@ impl AccountCommands {
AccountCommands::Display { name } => {
client.display_principal(&name).await;
}
AccountCommands::List { from, limit } => {
AccountCommands::List {
filter,
limit,
page,
} => {
client
.list_principals("individual", "Account", from, limit)
.list_principals("individual", "Account", filter, page, limit)
.await;
}
}
@ -245,11 +249,7 @@ impl AccountCommands {
impl Client {
pub async fn display_principal(&self, name: &str) {
let principal = self
.http_request::<Principal, String>(
Method::GET,
&format!("/admin/principal/{name}"),
None,
)
.http_request::<Principal, String>(Method::GET, &format!("/api/principal/{name}"), None)
.await;
let mut table = Table::new();
if let Some(name) = principal.name {
@ -318,31 +318,35 @@ impl Client {
&self,
record_type: &str,
record_name: &str,
from: Option<String>,
filter: Option<String>,
page: Option<usize>,
limit: Option<usize>,
) {
let mut query = form_urlencoded::Serializer::new("/admin/principal?".to_string());
let mut query = form_urlencoded::Serializer::new("/api/principal?".to_string());
query.append_pair("type", record_type);
if let Some(from) = &from {
query.append_pair("from", from);
if let Some(filter) = &filter {
query.append_pair("filter", filter);
}
if let Some(limit) = limit {
query.append_pair("limit", &limit.to_string());
}
if let Some(page) = page {
query.append_pair("page", &page.to_string());
}
let results = self
.http_request::<Vec<String>, String>(Method::GET, &query.finish(), None)
.http_request::<ListResponse, String>(Method::GET, &query.finish(), None)
.await;
if !results.is_empty() {
if !results.items.is_empty() {
let mut table = Table::new();
table.add_row(Row::new(vec![
Cell::new(&format!("{record_name} Name")).with_style(Attr::Bold)
]));
for domain in &results {
table.add_row(Row::new(vec![Cell::new(domain)]));
for item in &results.items {
table.add_row(Row::new(vec![Cell::new(item)]));
}
eprintln!();
@ -352,13 +356,19 @@ impl Client {
eprintln!(
"\n\n{} {}{} found.\n",
results.len(),
results.total,
record_name.to_ascii_lowercase(),
if results.len() == 1 { "" } else { "s" }
if results.total == 1 { "" } else { "s" }
);
}
}
#[derive(Debug, serde::Deserialize)]
struct ListResponse {
pub total: usize,
pub items: Vec<String>,
}
impl Display for Type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {

View file

@ -190,10 +190,12 @@ pub enum AccountCommands {
/// List all user accounts
List {
/// Starting point for listing accounts
from: Option<String>,
/// Filter accounts by keywords
filter: Option<String>,
/// Maximum number of accounts to list
limit: Option<usize>,
/// Page number
page: Option<usize>,
},
}
@ -255,10 +257,12 @@ pub enum ListCommands {
/// List all mailing lists
List {
/// Starting point for listing mailing lists
from: Option<String>,
/// Filter mailing lists by keywords
filter: Option<String>,
/// Maximum number of mailing lists to list
limit: Option<usize>,
/// Page number
page: Option<usize>,
},
}
@ -320,10 +324,12 @@ pub enum GroupCommands {
/// List all groups
List {
/// Starting point for listing groups
from: Option<String>,
/// Filter groups by keywords
filter: Option<String>,
/// Maximum number of groups to list
limit: Option<usize>,
/// Page number
page: Option<usize>,
},
}

View file

@ -32,19 +32,19 @@ impl ServerCommands {
match self {
ServerCommands::DatabaseMaintenance {} => {
client
.http_request::<Value, String>(Method::GET, "/admin/store/maintenance", None)
.http_request::<Value, String>(Method::GET, "/api/store/maintenance", None)
.await;
eprintln!("Success.");
}
ServerCommands::ReloadCertificates {} => {
client
.http_request::<Value, String>(Method::GET, "/admin/reload/certificates", None)
.http_request::<Value, String>(Method::GET, "/api/reload/certificates", None)
.await;
eprintln!("Success.");
}
ServerCommands::ReloadConfig {} => {
client
.http_request::<Value, String>(Method::GET, "/admin/reload/config", None)
.http_request::<Value, String>(Method::GET, "/api/reload/config", None)
.await;
eprintln!("Success.");
}
@ -52,7 +52,7 @@ impl ServerCommands {
client
.http_request::<Value, _>(
Method::POST,
"/admin/config",
"/api/config",
Some(vec![(key.clone(), value.unwrap_or_default())]),
)
.await;
@ -62,7 +62,7 @@ impl ServerCommands {
client
.http_request::<Value, String>(
Method::DELETE,
&format!("/admin/config/{key}"),
&format!("/api/config/{key}"),
None,
)
.await;
@ -72,7 +72,7 @@ impl ServerCommands {
let results = client
.http_request::<Vec<(String, String)>, String>(
Method::GET,
&format!("/admin/config/{}", prefix.unwrap_or_default()),
&format!("/api/config/{}", prefix.unwrap_or_default()),
None,
)
.await;

View file

@ -36,7 +36,7 @@ impl DomainCommands {
client
.http_request::<Value, String>(
Method::POST,
&format!("/admin/domain/{name}"),
&format!("/api/domain/{name}"),
None,
)
.await;
@ -46,7 +46,7 @@ impl DomainCommands {
client
.http_request::<Value, String>(
Method::DELETE,
&format!("/admin/domain/{name}"),
&format!("/api/domain/{name}"),
None,
)
.await;
@ -54,9 +54,9 @@ impl DomainCommands {
}
DomainCommands::List { from, limit } => {
let query = if from.is_none() && limit.is_none() {
Cow::Borrowed("/admin/domain")
Cow::Borrowed("/api/domain")
} else {
let mut query = "/admin/domain?".to_string();
let mut query = "/api/domain?".to_string();
if let Some(from) = &from {
query.push_str(&format!("from={from}"));
}

View file

@ -50,13 +50,13 @@ impl GroupCommands {
..Default::default()
};
let account_id = client
.http_request::<u32, _>(Method::POST, "/admin/principal", Some(principal))
.http_request::<u32, _>(Method::POST, "/api/principal", Some(principal))
.await;
if let Some(members) = members {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(vec![PrincipalUpdate::set(
PrincipalField::Members,
PrincipalValue::StringList(members),
@ -103,7 +103,7 @@ impl GroupCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(changes),
)
.await;
@ -116,7 +116,7 @@ impl GroupCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
members
.into_iter()
@ -136,7 +136,7 @@ impl GroupCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
members
.into_iter()
@ -155,8 +155,14 @@ impl GroupCommands {
GroupCommands::Display { name } => {
client.display_principal(&name).await;
}
GroupCommands::List { from, limit } => {
client.list_principals("group", "Group", from, limit).await;
GroupCommands::List {
filter,
limit,
page,
} => {
client
.list_principals("group", "Group", filter, page, limit)
.await;
}
}
}

View file

@ -50,13 +50,13 @@ impl ListCommands {
..Default::default()
};
let account_id = client
.http_request::<u32, _>(Method::POST, "/admin/principal", Some(principal))
.http_request::<u32, _>(Method::POST, "/api/principal", Some(principal))
.await;
if let Some(members) = members {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(vec![PrincipalUpdate::set(
PrincipalField::Members,
PrincipalValue::StringList(members),
@ -103,7 +103,7 @@ impl ListCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(changes),
)
.await;
@ -116,7 +116,7 @@ impl ListCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
members
.into_iter()
@ -136,7 +136,7 @@ impl ListCommands {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
&format!("/api/principal/{name}"),
Some(
members
.into_iter()
@ -155,9 +155,13 @@ impl ListCommands {
ListCommands::Display { name } => {
client.display_principal(&name).await;
}
ListCommands::List { from, limit } => {
ListCommands::List {
filter,
limit,
page,
} => {
client
.list_principals("list", "Mailing List", from, limit)
.list_principals("list", "Mailing List", filter, page, limit)
.await;
}
}

View file

@ -102,7 +102,7 @@ impl QueueCommands {
for (message, id) in client
.http_request::<Vec<Option<Message>>, String>(
Method::GET,
&build_query("/admin/queue/status?ids=", chunk),
&build_query("/api/queue/status?ids=", chunk),
None,
)
.await
@ -176,7 +176,7 @@ impl QueueCommands {
for (message, id) in client
.http_request::<Vec<Option<Message>>, String>(
Method::GET,
&build_query("/admin/queue/status?ids=", &parse_ids(&ids)),
&build_query("/api/queue/status?ids=", &parse_ids(&ids)),
None,
)
.await
@ -316,7 +316,7 @@ impl QueueCommands {
std::process::exit(1);
}
let mut query = form_urlencoded::Serializer::new("/admin/queue/retry?".to_string());
let mut query = form_urlencoded::Serializer::new("/api/queue/retry?".to_string());
if let Some(filter) = &domain {
query.append_pair("filter", filter);
@ -371,8 +371,7 @@ impl QueueCommands {
std::process::exit(1);
}
let mut query =
form_urlencoded::Serializer::new("/admin/queue/cancel?".to_string());
let mut query = form_urlencoded::Serializer::new("/api/queue/cancel?".to_string());
if let Some(filter) = &rcpt {
query.append_pair("filter", filter);
@ -414,7 +413,7 @@ impl Client {
before: &Option<DateTime>,
after: &Option<DateTime>,
) -> Vec<u64> {
let mut query = form_urlencoded::Serializer::new("/admin/queue/list?".to_string());
let mut query = form_urlencoded::Serializer::new("/api/queue/list?".to_string());
if let Some(sender) = from {
query.append_pair("from", sender);

View file

@ -51,7 +51,7 @@ impl ReportCommands {
page_size,
} => {
let stdout = Term::buffered_stdout();
let mut query = form_urlencoded::Serializer::new("/admin/report/list?".to_string());
let mut query = form_urlencoded::Serializer::new("/api/report/list?".to_string());
if let Some(domain) = &domain {
query.append_pair("domain", domain);
@ -78,7 +78,7 @@ impl ReportCommands {
for (report, id) in client
.http_request::<Vec<Option<Report>>, String>(
Method::GET,
&format!("/admin/report/status?ids={}", chunk.join(",")),
&format!("/api/report/status?ids={}", chunk.join(",")),
None,
)
.await
@ -117,7 +117,7 @@ impl ReportCommands {
for (report, id) in client
.http_request::<Vec<Option<Report>>, String>(
Method::GET,
&format!("/admin/report/status?ids={}", ids.join(",")),
&format!("/api/report/status?ids={}", ids.join(",")),
None,
)
.await
@ -173,7 +173,7 @@ impl ReportCommands {
for (success, id) in client
.http_request::<Vec<bool>, String>(
Method::GET,
&format!("/admin/report/cancel?ids={}", ids.join(",")),
&format!("/api/report/cancel?ids={}", ids.join(",")),
None,
)
.await

View file

@ -55,9 +55,8 @@ pub trait ManageDirectory: Sized {
async fn delete_account(&self, by: QueryBy<'_>) -> crate::Result<()>;
async fn list_accounts(
&self,
start_from: Option<&str>,
filter: Option<&str>,
typ: Option<Type>,
limit: usize,
) -> crate::Result<Vec<String>>;
async fn map_group_ids(&self, principal: Principal<u32>) -> crate::Result<Principal<String>>;
async fn map_group_names(
@ -67,11 +66,7 @@ pub trait ManageDirectory: Sized {
) -> crate::Result<Principal<u32>>;
async fn create_domain(&self, domain: &str) -> crate::Result<()>;
async fn delete_domain(&self, domain: &str) -> crate::Result<()>;
async fn list_domains(
&self,
start_from: Option<&str>,
limit: usize,
) -> crate::Result<Vec<String>>;
async fn list_domains(&self, filter: Option<&str>) -> crate::Result<Vec<String>>;
async fn init(self) -> crate::Result<Self>;
}
@ -802,61 +797,88 @@ impl ManageDirectory for Store {
async fn list_accounts(
&self,
start_from: Option<&str>,
filter: Option<&str>,
typ: Option<Type>,
limit: usize,
) -> crate::Result<Vec<String>> {
let from_key = ValueKey::from(ValueClass::Directory(DirectoryClass::NameToId(
start_from.unwrap_or("").as_bytes().to_vec(),
)));
let from_key = ValueKey::from(ValueClass::Directory(DirectoryClass::NameToId(vec![])));
let to_key = ValueKey::from(ValueClass::Directory(DirectoryClass::NameToId(vec![
u8::MAX;
10
])));
let mut results = Vec::with_capacity(limit);
let mut results = Vec::new();
self.iterate(
IterateParams::new(from_key, to_key)
.set_values(typ.is_some())
.ascending(),
IterateParams::new(from_key, to_key).ascending(),
|key, value| {
if typ.map_or(true, |t| {
PrincipalIdType::deserialize(value)
.map(|v| v.typ == t)
.unwrap_or(false)
}) {
results.push(
let pt = PrincipalIdType::deserialize(value)?;
if typ.map_or(true, |t| pt.typ == t) {
results.push((
pt.account_id,
String::from_utf8_lossy(key.get(1..).unwrap_or_default()).into_owned(),
);
));
}
Ok(limit == 0 || results.len() < limit)
Ok(true)
},
)
.await?;
Ok(results)
if let Some(filter) = filter {
let mut filtered = Vec::new();
let filters = filter
.split_whitespace()
.map(|r| r.to_lowercase())
.collect::<Vec<_>>();
for (account_id, account_name) in results {
let principal = self
.get_value::<Principal<u32>>(ValueKey::from(ValueClass::Directory(
DirectoryClass::Principal(account_id),
)))
.await?
.ok_or_else(|| {
DirectoryError::Management(ManagementError::NotFound(
account_id.to_string(),
))
})?;
if filters.iter().all(|f| {
principal.name.to_lowercase().contains(f)
|| principal
.description
.as_ref()
.map_or(false, |d| d.to_lowercase().contains(f))
|| principal
.emails
.iter()
.any(|email| email.to_lowercase().contains(f))
}) {
filtered.push(account_name);
}
}
async fn list_domains(
&self,
start_from: Option<&str>,
limit: usize,
) -> crate::Result<Vec<String>> {
let from_key = ValueKey::from(ValueClass::Directory(DirectoryClass::Domain(
start_from.unwrap_or("").as_bytes().to_vec(),
)));
Ok(filtered)
} else {
Ok(results.into_iter().map(|(_, name)| name).collect())
}
}
async fn list_domains(&self, filter: Option<&str>) -> crate::Result<Vec<String>> {
let from_key = ValueKey::from(ValueClass::Directory(DirectoryClass::Domain(vec![])));
let to_key = ValueKey::from(ValueClass::Directory(DirectoryClass::Domain(vec![
u8::MAX;
10
])));
let mut results = Vec::with_capacity(limit);
let mut results = Vec::new();
self.iterate(
IterateParams::new(from_key, to_key).no_values().ascending(),
|key, _| {
results
.push(String::from_utf8_lossy(key.get(1..).unwrap_or_default()).into_owned());
Ok(limit == 0 || results.len() < limit)
let domain = String::from_utf8_lossy(key.get(1..).unwrap_or_default()).into_owned();
if filter.map_or(true, |f| domain.contains(f)) {
results.push(domain);
}
Ok(true)
},
)
.await?;

View file

@ -86,8 +86,9 @@ impl JMAP {
}
("principal", None, &Method::GET) => {
// List principal ids
let mut from_key = None;
let mut filter = None;
let mut typ = None;
let mut page: usize = 0;
let mut limit: usize = 0;
if let Some(query) = req.uri().query() {
@ -96,26 +97,40 @@ impl JMAP {
"limit" => {
limit = value.parse().unwrap_or_default();
}
"page" => {
page = value.parse().unwrap_or_default();
}
"type" => {
typ = Type::parse(value.as_ref());
}
"from" => {
from_key = value.into();
"filter" => {
filter = value.into();
}
_ => {}
}
}
}
match self
.store
.list_accounts(from_key.as_deref(), typ, limit)
.await
{
Ok(accounts) => JsonResponse::new(json!({
"data": accounts,
match self.store.list_accounts(filter.as_deref(), typ).await {
Ok(accounts) => {
let (total, accounts) = if limit > 0 {
let offset = page.saturating_sub(1) * limit;
(
accounts.len(),
accounts.into_iter().skip(offset).take(limit).collect(),
)
} else {
(accounts.len(), accounts)
};
JsonResponse::new(json!({
"data": {
"items": accounts,
"total": total,
},
}))
.into_http_response(),
.into_http_response()
}
Err(err) => map_directory_error(err),
}
}
@ -232,8 +247,9 @@ impl JMAP {
}
}
("domain", None, &Method::GET) => {
// List principal ids
let mut from_key = None;
// List domains
let mut filter = None;
let mut page: usize = 0;
let mut limit: usize = 0;
if let Some(query) = req.uri().query() {
@ -242,19 +258,37 @@ impl JMAP {
"limit" => {
limit = value.parse().unwrap_or_default();
}
"from" => {
from_key = value.into();
"page" => {
page = value.parse().unwrap_or_default();
}
"filter" => {
filter = value.into();
}
_ => {}
}
}
}
match self.store.list_domains(from_key.as_deref(), limit).await {
Ok(domains) => JsonResponse::new(json!({
"data": domains,
match self.store.list_domains(filter.as_deref()).await {
Ok(domains) => {
let (total, domains) = if limit > 0 {
let offset = page.saturating_sub(1) * limit;
(
domains.len(),
domains.into_iter().skip(offset).take(limit).collect(),
)
} else {
(domains.len(), domains)
};
JsonResponse::new(json!({
"data": {
"items": domains,
"total": total,
},
}))
.into_http_response(),
.into_http_response()
}
Err(err) => map_directory_error(err),
}
}

View file

@ -267,7 +267,7 @@ pub async fn parse_jmap_request(
_ => (),
}
}
"admin" => {
"api" => {
// Make sure the user is a superuser
let body = match jmap.authenticate_headers(&req, remote_ip).await {
Ok(Some((_, access_token))) if access_token.is_super_user() => {

View file

@ -1,19 +1,18 @@
#!/bin/bash
URL="https://127.0.0.1:443"
CREDENTIALS="admin:secret"
export URL="https://127.0.0.1:443" CREDENTIALS="admin:secret"
cargo run -p stalwart-cli -- domain create example.org
cargo run -p stalwart-cli -- account create john 12345 -d "John Doe" -a john@example.org -a john.doe@example.org
#cargo run -p stalwart-cli -- account create jane abcde -d "Jane Doe" -a jane@example.org
#cargo run -p stalwart-cli -- account create bill xyz12 -d "Bill Foobar" -a bill@example.org
#cargo run -p stalwart-cli -- group create sales -d "Sales Department"
#cargo run -p stalwart-cli -- group create support -d "Technical Support"
#cargo run -p stalwart-cli -- account add-to-group john sales support
#cargo run -p stalwart-cli -- account remove-from-group john support
#cargo run -p stalwart-cli -- account add-email jane jane.doe@example.org
#cargo run -p stalwart-cli -- list create everyone everyone@example.org
#cargo run -p stalwart-cli -- list add-members everyone jane john bill
#cargo run -p stalwart-cli -- account list
#cargo run -p stalwart-cli -- import messages --format mbox john _ignore/dovecot-crlf
#cargo run -p stalwart-cli -- import messages --format maildir john /var/mail/john
cargo run -p stalwart-cli -- account create jane abcde -d "Jane Doe" -a jane@example.org
cargo run -p stalwart-cli -- account create bill xyz12 -d "Bill Foobar" -a bill@example.org
cargo run -p stalwart-cli -- group create sales -d "Sales Department"
cargo run -p stalwart-cli -- group create support -d "Technical Support"
cargo run -p stalwart-cli -- account add-to-group john sales support
cargo run -p stalwart-cli -- account remove-from-group john support
cargo run -p stalwart-cli -- account add-email jane jane.doe@example.org
cargo run -p stalwart-cli -- list create everyone everyone@example.org
cargo run -p stalwart-cli -- list add-members everyone jane john bill
cargo run -p stalwart-cli -- account list
cargo run -p stalwart-cli -- import messages --format mbox john _ignore/dovecot-crlf
cargo run -p stalwart-cli -- import messages --format maildir john /var/mail/john

View file

@ -503,32 +503,26 @@ async fn internal_directory() {
// List accounts
assert_eq!(
store.list_accounts(None, None, 0).await.unwrap(),
store.list_accounts(None, None).await.unwrap(),
vec!["jane", "john.doe", "list", "sales", "support"]
);
assert_eq!(
store.list_accounts("john".into(), None, 2).await.unwrap(),
vec!["john.doe", "list"]
store.list_accounts("john".into(), None).await.unwrap(),
vec!["john.doe"]
);
assert_eq!(
store
.list_accounts(None, Type::Individual.into(), 0)
.list_accounts(None, Type::Individual.into())
.await
.unwrap(),
vec!["jane", "john.doe"]
);
assert_eq!(
store
.list_accounts(None, Type::Group.into(), 0)
.await
.unwrap(),
store.list_accounts(None, Type::Group.into()).await.unwrap(),
vec!["sales", "support"]
);
assert_eq!(
store
.list_accounts(None, Type::List.into(), 0)
.await
.unwrap(),
store.list_accounts(None, Type::List.into()).await.unwrap(),
vec!["list"]
);
@ -572,7 +566,7 @@ async fn internal_directory() {
);
assert!(!store.rcpt("john.doe@example.org").await.unwrap());
assert_eq!(
store.list_accounts(None, None, 0).await.unwrap(),
store.list_accounts(None, None).await.unwrap(),
vec!["jane", "list", "sales", "support"]
);
assert_eq!(