Merge branch 'main' into fix/rename-id-to-message-id

This commit is contained in:
rustmailer
2025-12-26 19:59:25 +08:00
committed by GitHub
184 changed files with 23973 additions and 3193 deletions
+28 -90
View File
@@ -16,16 +16,12 @@
// 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/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::permissions::Permission;
use crate::modules::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
@@ -33,9 +29,6 @@ pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
@@ -44,31 +37,15 @@ impl AccessTokenApi {
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
@@ -80,13 +57,18 @@ impl AccessTokenApi {
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
/// Creates a new api token.
#[oai(
path = "/access-token",
method = "post",
@@ -98,59 +80,15 @@ impl AccessTokenApi {
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
Ok(PlainText(token_string))
}
}
+90 -45
View File
@@ -16,20 +16,23 @@
// 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/>.
use std::collections::BTreeSet;
use std::collections::{HashMap, HashSet};
use crate::modules::account::grant::BatchAccountRoleRequest;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::view::AccountResp;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::modules::users::permissions::Permission;
use crate::modules::users::BichonUser;
use crate::raise_error;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
@@ -52,7 +55,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::get(account_id).await?))
}
@@ -69,7 +74,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::delete(account_id).await?)
}
@@ -81,14 +88,10 @@ impl AccountApi {
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let account = AccountModel::create_account(context.user.id, payload.0).await?;
Ok(Json(account))
}
@@ -107,7 +110,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
@@ -122,35 +127,61 @@ impl AccountApi {
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
let user_map: HashMap<u64, BichonUser> = BichonUser::list_all()
.await?
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
if authorized_ids.is_empty() {
return Ok(Json(DataPage {
current_page: page.0,
page_size: page_size.0,
total_items: 0,
items: vec![],
total_pages: Some(0),
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
};
let items = page_data
.items
.into_iter()
.map(|account| AccountResp::from_model(account, &user_map))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
Ok(Json(DataPage {
current_page: page_data.current_page,
page_size: page_data.page_size,
total_items: page_data.total_items,
total_pages: page_data.total_pages,
items,
}))
}
/// Get the running state of an account
@@ -167,7 +198,9 @@ impl AccountApi {
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
@@ -190,13 +223,25 @@ impl AccountApi {
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let is_admin = context.user.is_admin().await;
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
if is_admin {
return Ok(Json(minimal_list));
}
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
Ok(Json(result))
}
#[oai(path = "/accounts/access/assignments", method = "post")]
async fn batch_assign_account_role(
&self,
req: Json<BatchAccountRoleRequest>,
context: ClientContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
Ok(())
}
}
+8 -2
View File
@@ -16,12 +16,13 @@
// 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/>.
use crate::modules::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
@@ -40,8 +41,13 @@ impl AutoConfigApi {
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
email_address: Path<String>,
context: ClientContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {
+4 -1
View File
@@ -21,6 +21,7 @@ use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -43,7 +44,9 @@ impl ImportApi {
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context.require_root()?;
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}
+3 -2
View File
@@ -16,7 +16,6 @@
// 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/>.
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::list::get_account_mailboxes;
@@ -52,7 +51,9 @@ impl MailBoxApi {
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
+53 -11
View File
@@ -31,12 +31,14 @@ use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::Body;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use std::collections::HashSet;
use tantivy::schema::Facet;
pub struct MessageApi;
@@ -57,7 +59,9 @@ impl MessageApi {
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context.require_account_access(*account_id)?;
context
.require_permission(Some(*account_id), Permission::DATA_DELETE)
.await?;
}
Ok(delete_messages_impl(request).await?)
}
@@ -78,7 +82,9 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
@@ -95,8 +101,15 @@ impl MessageApi {
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
@@ -119,7 +132,9 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
@@ -140,7 +155,9 @@ impl MessageApi {
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(retrieve_email_content(account_id, message_id.0).await?))
}
@@ -160,7 +177,9 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
let message_id = message_id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?;
let body = Body::from_async_read(reader);
@@ -188,7 +207,9 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let message_id = message_id.0;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
@@ -202,8 +223,18 @@ impl MessageApi {
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER.get_all_tags(authorized_ids).await?,
))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -212,12 +243,23 @@ impl MessageApi {
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
async fn update_envelope_tags(
&self,
req: Json<UpdateTagsRequest>,
context: ClientContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;
+8 -1
View File
@@ -25,7 +25,10 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::{bichon_version, modules::rest::api::import::ImportApi};
use crate::{
bichon_version,
modules::rest::api::{import::ImportApi, users::UsersApi},
};
pub mod access_token;
pub mod account;
@@ -35,6 +38,7 @@ pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
pub mod users;
#[derive(Tags)]
pub enum ApiTags {
@@ -46,6 +50,7 @@ pub enum ApiTags {
Message,
System,
Import,
Users,
}
type RustMailOpenApi = (
@@ -57,6 +62,7 @@ type RustMailOpenApi = (
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
),
"BichonApi",
bichon_version!(),
+59 -16
View File
@@ -16,7 +16,7 @@
// 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/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
@@ -25,6 +25,7 @@ use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Json, PlainText};
@@ -49,14 +50,26 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
})?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(oauth2));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
oauth2.scrub_sensitive_fields();
Ok(Json(oauth2))
}
/// Deletes an OAuth2 configuration by name.
@@ -74,7 +87,9 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::delete(id.0).await?)
}
@@ -93,7 +108,9 @@ impl OAuth2Api {
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
@@ -115,7 +132,9 @@ impl OAuth2Api {
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
@@ -138,10 +157,23 @@ impl OAuth2Api {
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(list));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
for item in &mut list.items {
item.scrub_sensitive_fields();
}
Ok(Json(list))
}
/// Generates an OAuth2 authorization URL for a specific account.
@@ -159,8 +191,14 @@ impl OAuth2Api {
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
@@ -180,7 +218,9 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
@@ -218,10 +258,13 @@ impl OAuth2Api {
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
Ok(())
}
}
+43 -8
View File
@@ -16,13 +16,15 @@
// 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/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::SystemConfigurations;
use crate::modules::users::permissions::Permission;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
@@ -60,14 +62,20 @@ impl SystemApi {
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get(context).await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
async fn list_proxy(&self, context: ClientContext) -> ApiResult<Json<Vec<Proxy>>> {
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(None, Permission::ROOT),
])
.await?;
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -82,7 +90,9 @@ impl SystemApi {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::delete(id.0).await?)
}
@@ -94,14 +104,18 @@ impl SystemApi {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
@@ -114,7 +128,28 @@ impl SystemApi {
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::update(id.0, url.0).await?)
}
/// Get system configurations.
///
/// Returns a read-only snapshot of the server configuration
/// resolved at startup. Sensitive values are not exposed.
#[oai(
method = "get",
path = "/system-configurations",
operation_id = "get_system_configurations"
)]
async fn get_system_configurations(
&self,
context: ClientContext,
) -> ApiResult<Json<SystemConfigurations>> {
context
.require_permission(None, Permission::ROOT)
.await?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}
}
+217
View File
@@ -0,0 +1,217 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// 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.
//
// 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/>.
use std::collections::BTreeMap;
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::minimal::MinimalUser;
use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole;
use crate::modules::users::view::UserView;
use crate::modules::users::BichonUser;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct UsersApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
async fn remove_role(
&self,
/// The Role ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
}
/// Create a new account
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
async fn create_role(
&self,
/// Role creation request payload
payload: Json<RoleCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
Ok(Json(role))
}
/// Update an existing account
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
async fn update_role(
&self,
/// The Role ID to update
id: Path<u64>,
/// Role update request payload
payload: Json<RoleUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: ClientContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = BichonUser::list_all().await?;
let users = users
.into_iter()
.map(|u| u.to_current_user(&role_lookup))
.collect();
Ok(Json(users))
}
#[oai(
path = "/user-tokens/:id",
method = "get",
operation_id = "get_user_tokens"
)]
async fn get_user_tokens(
&self,
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(tokens))
}
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
async fn remove_user(
&self,
/// The User ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(BichonUser::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
async fn create_user(
&self,
payload: Json<UserCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = BichonUser::create(payload.0).await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_current_user(&role_lookup)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
async fn update_user(
&self,
id: Path<u64>,
payload: Json<UserUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(BichonUser::update(target_id, update_data).await?)
}
#[oai(
path = "/current-user",
method = "get",
operation_id = "get_current_user"
)]
async fn get_current_user(&self, context: ClientContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.user.to_current_user(&role_lookup)))
}
#[oai(
path = "/minimal-user-list",
method = "get",
operation_id = "get_minimal_user_list"
)]
async fn get_minimal_user_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
Ok(Json(minimal_list))
}
}