use crate::{
error::{DynHttpError, HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
middleware::tenant::{TenantDb, TenantParams, TenantSearch, TenantStorage},
models::admin::{
HttpAdminError, TenantDocumentBoxesRequest, TenantDocumentBoxesResponse,
TenantStatsResponse,
},
};
use axum::{Extension, Json, extract::Path, http::StatusCode};
use axum_valid::Garde;
use docbox_core::{
database::{
DatabasePoolCache,
models::{
document_box::{DocumentBox, WithScope},
file::File,
folder::Folder,
link::Link,
user::User,
},
utils::DatabaseErrorExt,
},
document_box::search_document_box::{ResolvedSearchResult, search_document_boxes_admin},
processing::ProcessingLayer,
purge::purge_expired_presigned_tasks::purge_expired_presigned_tasks,
search::models::{
AdminSearchRequest, AdminSearchResultResponse, AdminUsersResults, SearchResultItem,
UsersRequest,
},
storage::StorageLayerFactory,
tenant::tenant_cache::TenantCache,
};
use std::sync::Arc;
use tokio::{join, try_join};
pub const ADMIN_TAG: &str = "Admin";
#[utoipa::path(
post,
operation_id = "admin_tenant_boxes",
tag = ADMIN_TAG,
path = "/admin/boxes",
responses(
(status = 201, description = "Searched successfully", body = TenantDocumentBoxesResponse),
(status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all, fields(?req))]
pub async fn tenant_boxes(
TenantDb(db): TenantDb,
Garde(Json(req)): Garde<Json<TenantDocumentBoxesRequest>>,
) -> HttpResult<TenantDocumentBoxesResponse> {
let offset = req.offset.unwrap_or(0);
let limit = req.size.unwrap_or(100);
let (document_boxes, total) = match req.query {
Some(query) if !query.is_empty() => {
let mut query = query
.replace("*", "%")
.replace("_", "\\_");
let has_wildcard = query.chars().any(|char| matches!(char, '*' | '%'));
if !has_wildcard {
query.push('%');
}
let document_boxes = DocumentBox::search_query(&db, &query, offset, limit as u64)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query document boxes");
HttpCommonError::ServerError
})?;
let total = DocumentBox::search_total(&db, &query)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query document boxes total");
HttpCommonError::ServerError
})?;
(document_boxes, total)
}
_ => {
let document_boxes = DocumentBox::query(&db, offset, limit as u64)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query document boxes");
HttpCommonError::ServerError
})?;
let total = DocumentBox::total(&db).await.map_err(|error| {
tracing::error!(?error, "failed to query document boxes total");
HttpCommonError::ServerError
})?;
(document_boxes, total)
}
};
Ok(Json(TenantDocumentBoxesResponse {
results: document_boxes,
total,
}))
}
#[utoipa::path(
get,
operation_id = "admin_tenant_stats",
tag = ADMIN_TAG,
path = "/admin/tenant-stats",
responses(
(status = 201, description = "Got stats successfully", body = TenantStatsResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all)]
pub async fn tenant_stats(TenantDb(db): TenantDb) -> HttpResult<TenantStatsResponse> {
let total_files_future = File::total_count(&db);
let total_links_future = Link::total_count(&db);
let total_folders_future = Folder::total_count(&db);
let file_size_future = File::total_size(&db);
let (total_files, total_links, total_folders, file_size) = join!(
total_files_future,
total_links_future,
total_folders_future,
file_size_future
);
let total_files = total_files.map_err(|error| {
tracing::error!(?error, "failed to query tenant total files");
HttpCommonError::ServerError
})?;
let total_links = total_links.map_err(|error| {
tracing::error!(?error, "failed to query tenant total links");
HttpCommonError::ServerError
})?;
let total_folders = total_folders.map_err(|error| {
tracing::error!(?error, "failed to query tenant total folders");
HttpCommonError::ServerError
})?;
let file_size = file_size.map_err(|error| {
tracing::error!(?error, "failed to query tenant files size");
HttpCommonError::ServerError
})?;
Ok(Json(TenantStatsResponse {
total_files,
total_folders,
total_links,
file_size,
}))
}
#[utoipa::path(
post,
operation_id = "admin_search_tenant",
tag = ADMIN_TAG,
path = "/admin/search",
responses(
(status = 201, description = "Searched successfully", body = AdminSearchResultResponse),
(status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all, fields(?req))]
pub async fn search_tenant(
TenantDb(db): TenantDb,
TenantSearch(search): TenantSearch,
Garde(Json(req)): Garde<Json<AdminSearchRequest>>,
) -> HttpResult<AdminSearchResultResponse> {
if req.scopes.is_empty() {
return Ok(Json(AdminSearchResultResponse {
total_hits: 0,
results: vec![],
}));
}
let resolved = search_document_boxes_admin(&db, &search, req)
.await
.map_err(|error| {
tracing::error!(?error, "failed to perform admin search");
HttpCommonError::ServerError
})?;
let out: Vec<WithScope<SearchResultItem>> = resolved
.results
.into_iter()
.map(|ResolvedSearchResult { result, data, path }| WithScope {
data: SearchResultItem {
path,
score: result.score,
data,
page_matches: result.page_matches,
total_hits: result.total_hits,
name_match: result.name_match,
content_match: result.content_match,
},
scope: result.document_box,
})
.collect();
Ok(Json(AdminSearchResultResponse {
total_hits: resolved.total_hits,
results: out,
}))
}
#[utoipa::path(
post,
operation_id = "admin_reprocess_octet_stream_files",
tag = ADMIN_TAG,
path = "/admin/reprocess-octet-stream-files",
responses(
(status = 204, description = "Reprocessed successfully", body = AdminSearchResultResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all)]
pub async fn reprocess_octet_stream_files_tenant(
TenantDb(db): TenantDb,
TenantSearch(search): TenantSearch,
TenantStorage(storage): TenantStorage,
Extension(processing): Extension<ProcessingLayer>,
) -> HttpStatusResult {
docbox_core::files::reprocess_octet_stream_files::reprocess_octet_stream_files(
&db,
&search,
&storage,
&processing,
)
.await
.map_err(|error| {
tracing::error!(?error, "failed to reprocess octet-stream files");
HttpCommonError::ServerError
})?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
operation_id = "admin_rebuild_search_index",
tag = ADMIN_TAG,
path = "/admin/rebuild-search-index",
responses(
(status = 204, description = "Rebuilt successfully", body = ()),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all)]
pub async fn rebuild_search_index_tenant(
TenantDb(db): TenantDb,
TenantSearch(search): TenantSearch,
TenantStorage(storage): TenantStorage,
) -> HttpStatusResult {
docbox_core::tenant::rebuild_tenant_index::rebuild_tenant_index(&db, &search, &storage)
.await
.map_err(|error| {
tracing::error!(?error, "failed to rebuilt tenant search index");
HttpCommonError::ServerError
})?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
operation_id = "admin_flush_database_pool_cache",
tag = ADMIN_TAG,
path = "/admin/flush-db-cache",
responses(
(status = 204, description = "Database cache flushed"),
)
)]
pub async fn flush_database_pool_cache(
Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
) -> HttpStatusResult {
db_cache.flush().await;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
operation_id = "admin_flush_tenant_cache",
tag = ADMIN_TAG,
path = "/admin/flush-tenant-cache",
responses(
(status = 204, description = "Tenant cache flushed"),
)
)]
pub async fn flush_tenant_cache(
Extension(tenant_cache): Extension<Arc<TenantCache>>,
) -> HttpStatusResult {
tenant_cache.flush().await;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
operation_id = "admin_purge_expired_presigned_tasks",
tag = ADMIN_TAG,
path = "/admin/purge-expired-presigned-tasks",
responses(
(status = 204, description = "Database cache flushed"),
(status = 500, description = "Failed to purge presigned cache", body = HttpErrorResponse),
)
)]
pub async fn http_purge_expired_presigned_tasks(
Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
Extension(storage_factory): Extension<StorageLayerFactory>,
) -> HttpStatusResult {
purge_expired_presigned_tasks(db_cache, storage_factory)
.await
.map_err(|_| HttpCommonError::ServerError)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
operation_id = "admin_list_users",
tag = ADMIN_TAG,
path = "/admin/users",
responses(
(status = 200, description = "Listed users successfully", body = AdminSearchResultResponse),
(status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all, fields(?req))]
pub async fn list_users(
TenantDb(db): TenantDb,
Garde(Json(req)): Garde<Json<UsersRequest>>,
) -> HttpResult<AdminUsersResults> {
let offset = req.offset.unwrap_or(0);
let limit = req.size.unwrap_or(100) as u64;
let total_future = User::total(&db);
let results_future = User::query(&db, offset, limit);
let (total, results) = try_join!(total_future, results_future).map_err(|error| {
tracing::error!(?error, "failed to query users");
HttpCommonError::ServerError
})?;
Ok(Json(AdminUsersResults { total, results }))
}
#[utoipa::path(
delete,
operation_id = "admin_delete_user",
tag = ADMIN_TAG,
path = "/admin/users/{id}",
responses(
(status = 204, description = "Listed users successfully", body = AdminSearchResultResponse),
(status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(TenantParams)
)]
#[tracing::instrument(skip_all, fields(id))]
pub async fn delete_user(TenantDb(db): TenantDb, Path(id): Path<String>) -> HttpStatusResult {
let user = User::find(&db, &id)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query user");
HttpCommonError::ServerError
})?
.ok_or(HttpAdminError::UnknownUser)?;
user.delete(&db).await.map_err(|error| {
if error.is_restrict() {
tracing::error!(
?error,
"attempted to delete a user without first deleting attached resources"
);
DynHttpError::from(HttpAdminError::UserResourcesAttached)
} else {
tracing::error!(?error, "failed to delete user");
DynHttpError::from(HttpCommonError::ServerError)
}
})?;
Ok(StatusCode::NO_CONTENT)
}