1use crate::{
4 error::{DynHttpError, HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
5 middleware::tenant::{TenantDb, TenantParams, TenantSearch, TenantStorage},
6 models::admin::{
7 HttpAdminError, TenantDocumentBoxesRequest, TenantDocumentBoxesResponse,
8 TenantStatsResponse,
9 },
10};
11use axum::{Extension, Json, extract::Path, http::StatusCode};
12use axum_valid::Garde;
13use docbox_core::{
14 database::{
15 DatabasePoolCache,
16 models::{
17 document_box::{DocumentBox, WithScope},
18 file::File,
19 folder::Folder,
20 link::Link,
21 user::User,
22 },
23 utils::DatabaseErrorExt,
24 },
25 document_box::search_document_box::{ResolvedSearchResult, search_document_boxes_admin},
26 processing::ProcessingLayer,
27 purge::purge_expired_presigned_tasks::purge_expired_presigned_tasks,
28 search::models::{
29 AdminSearchRequest, AdminSearchResultResponse, AdminUsersResults, SearchResultItem,
30 UsersRequest,
31 },
32 storage::StorageLayerFactory,
33 tenant::tenant_cache::TenantCache,
34};
35use std::sync::Arc;
36use tokio::{join, try_join};
37
38pub const ADMIN_TAG: &str = "Admin";
39
40#[utoipa::path(
45 post,
46 operation_id = "admin_tenant_boxes",
47 tag = ADMIN_TAG,
48 path = "/admin/boxes",
49 responses(
50 (status = 201, description = "Searched successfully", body = TenantDocumentBoxesResponse),
51 (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
52 (status = 500, description = "Internal server error", body = HttpErrorResponse)
53 ),
54 params(TenantParams)
55)]
56#[tracing::instrument(skip_all, fields(?req))]
57pub async fn tenant_boxes(
58 TenantDb(db): TenantDb,
59 Garde(Json(req)): Garde<Json<TenantDocumentBoxesRequest>>,
60) -> HttpResult<TenantDocumentBoxesResponse> {
61 let offset = req.offset.unwrap_or(0);
62 let limit = req.size.unwrap_or(100);
63
64 let (document_boxes, total) = match req.query {
65 Some(query) if !query.is_empty() => {
66 let mut query = query
68 .replace("*", "%")
70 .replace("_", "\\_");
72
73 let has_wildcard = query.chars().any(|char| matches!(char, '*' | '%'));
74
75 if !has_wildcard {
77 query.push('%');
78 }
79
80 let document_boxes = DocumentBox::search_query(&db, &query, offset, limit as u64)
81 .await
82 .map_err(|error| {
83 tracing::error!(?error, "failed to query document boxes");
84 HttpCommonError::ServerError
85 })?;
86
87 let total = DocumentBox::search_total(&db, &query)
88 .await
89 .map_err(|error| {
90 tracing::error!(?error, "failed to query document boxes total");
91 HttpCommonError::ServerError
92 })?;
93
94 (document_boxes, total)
95 }
96 _ => {
97 let document_boxes = DocumentBox::query(&db, offset, limit as u64)
98 .await
99 .map_err(|error| {
100 tracing::error!(?error, "failed to query document boxes");
101 HttpCommonError::ServerError
102 })?;
103
104 let total = DocumentBox::total(&db).await.map_err(|error| {
105 tracing::error!(?error, "failed to query document boxes total");
106 HttpCommonError::ServerError
107 })?;
108
109 (document_boxes, total)
110 }
111 };
112
113 Ok(Json(TenantDocumentBoxesResponse {
114 results: document_boxes,
115 total,
116 }))
117}
118
119#[utoipa::path(
124 get,
125 operation_id = "admin_tenant_stats",
126 tag = ADMIN_TAG,
127 path = "/admin/tenant-stats",
128 responses(
129 (status = 201, description = "Got stats successfully", body = TenantStatsResponse),
130 (status = 500, description = "Internal server error", body = HttpErrorResponse)
131 ),
132 params(TenantParams)
133)]
134#[tracing::instrument(skip_all)]
135pub async fn tenant_stats(TenantDb(db): TenantDb) -> HttpResult<TenantStatsResponse> {
136 let total_files_future = File::total_count(&db);
137 let total_links_future = Link::total_count(&db);
138 let total_folders_future = Folder::total_count(&db);
139 let file_size_future = File::total_size(&db);
140
141 let (total_files, total_links, total_folders, file_size) = join!(
142 total_files_future,
143 total_links_future,
144 total_folders_future,
145 file_size_future
146 );
147
148 let total_files = total_files.map_err(|error| {
149 tracing::error!(?error, "failed to query tenant total files");
150 HttpCommonError::ServerError
151 })?;
152
153 let total_links = total_links.map_err(|error| {
154 tracing::error!(?error, "failed to query tenant total links");
155 HttpCommonError::ServerError
156 })?;
157
158 let total_folders = total_folders.map_err(|error| {
159 tracing::error!(?error, "failed to query tenant total folders");
160 HttpCommonError::ServerError
161 })?;
162
163 let file_size = file_size.map_err(|error| {
164 tracing::error!(?error, "failed to query tenant files size");
165 HttpCommonError::ServerError
166 })?;
167
168 Ok(Json(TenantStatsResponse {
169 total_files,
170 total_folders,
171 total_links,
172 file_size,
173 }))
174}
175
176#[utoipa::path(
183 post,
184 operation_id = "admin_search_tenant",
185 tag = ADMIN_TAG,
186 path = "/admin/search",
187 responses(
188 (status = 201, description = "Searched successfully", body = AdminSearchResultResponse),
189 (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
190 (status = 500, description = "Internal server error", body = HttpErrorResponse)
191 ),
192 params(TenantParams)
193)]
194#[tracing::instrument(skip_all, fields(?req))]
195pub async fn search_tenant(
196 TenantDb(db): TenantDb,
197 TenantSearch(search): TenantSearch,
198 Garde(Json(req)): Garde<Json<AdminSearchRequest>>,
199) -> HttpResult<AdminSearchResultResponse> {
200 if req.scopes.is_empty() {
202 return Ok(Json(AdminSearchResultResponse {
203 total_hits: 0,
204 results: vec![],
205 }));
206 }
207
208 let resolved = search_document_boxes_admin(&db, &search, req)
209 .await
210 .map_err(|error| {
211 tracing::error!(?error, "failed to perform admin search");
212 HttpCommonError::ServerError
213 })?;
214
215 let out: Vec<WithScope<SearchResultItem>> = resolved
216 .results
217 .into_iter()
218 .map(|ResolvedSearchResult { result, data, path }| WithScope {
219 data: SearchResultItem {
220 path,
221 score: result.score,
222 data,
223 page_matches: result.page_matches,
224 total_hits: result.total_hits,
225 name_match: result.name_match,
226 content_match: result.content_match,
227 },
228 scope: result.document_box,
229 })
230 .collect();
231
232 Ok(Json(AdminSearchResultResponse {
233 total_hits: resolved.total_hits,
234 results: out,
235 }))
236}
237
238#[utoipa::path(
248 post,
249 operation_id = "admin_reprocess_octet_stream_files",
250 tag = ADMIN_TAG,
251 path = "/admin/reprocess-octet-stream-files",
252 responses(
253 (status = 204, description = "Reprocessed successfully", body = AdminSearchResultResponse),
254 (status = 500, description = "Internal server error", body = HttpErrorResponse)
255 ),
256 params(TenantParams)
257)]
258#[tracing::instrument(skip_all)]
259pub async fn reprocess_octet_stream_files_tenant(
260 TenantDb(db): TenantDb,
261 TenantSearch(search): TenantSearch,
262 TenantStorage(storage): TenantStorage,
263 Extension(processing): Extension<ProcessingLayer>,
264) -> HttpStatusResult {
265 docbox_core::files::reprocess_octet_stream_files::reprocess_octet_stream_files(
266 &db,
267 &search,
268 &storage,
269 &processing,
270 )
271 .await
272 .map_err(|error| {
273 tracing::error!(?error, "failed to reprocess octet-stream files");
274 HttpCommonError::ServerError
275 })?;
276
277 Ok(StatusCode::NO_CONTENT)
278}
279
280#[utoipa::path(
287 post,
288 operation_id = "admin_rebuild_search_index",
289 tag = ADMIN_TAG,
290 path = "/admin/rebuild-search-index",
291 responses(
292 (status = 204, description = "Rebuilt successfully", body = ()),
293 (status = 500, description = "Internal server error", body = HttpErrorResponse)
294 ),
295 params(TenantParams)
296)]
297#[tracing::instrument(skip_all)]
298pub async fn rebuild_search_index_tenant(
299 TenantDb(db): TenantDb,
300 TenantSearch(search): TenantSearch,
301 TenantStorage(storage): TenantStorage,
302) -> HttpStatusResult {
303 docbox_core::tenant::rebuild_tenant_index::rebuild_tenant_index(&db, &search, &storage)
304 .await
305 .map_err(|error| {
306 tracing::error!(?error, "failed to rebuilt tenant search index");
307 HttpCommonError::ServerError
308 })?;
309
310 Ok(StatusCode::NO_CONTENT)
311}
312
313#[utoipa::path(
319 post,
320 operation_id = "admin_flush_database_pool_cache",
321 tag = ADMIN_TAG,
322 path = "/admin/flush-db-cache",
323 responses(
324 (status = 204, description = "Database cache flushed"),
325 )
326)]
327pub async fn flush_database_pool_cache(
328 Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
329) -> HttpStatusResult {
330 db_cache.flush().await;
331 Ok(StatusCode::NO_CONTENT)
332}
333
334#[utoipa::path(
340 post,
341 operation_id = "admin_flush_tenant_cache",
342 tag = ADMIN_TAG,
343 path = "/admin/flush-tenant-cache",
344 responses(
345 (status = 204, description = "Tenant cache flushed"),
346 )
347)]
348pub async fn flush_tenant_cache(
349 Extension(tenant_cache): Extension<Arc<TenantCache>>,
350) -> HttpStatusResult {
351 tenant_cache.flush().await;
352 Ok(StatusCode::NO_CONTENT)
353}
354
355#[utoipa::path(
360 post,
361 operation_id = "admin_purge_expired_presigned_tasks",
362 tag = ADMIN_TAG,
363 path = "/admin/purge-expired-presigned-tasks",
364 responses(
365 (status = 204, description = "Database cache flushed"),
366 (status = 500, description = "Failed to purge presigned cache", body = HttpErrorResponse),
367 )
368)]
369pub async fn http_purge_expired_presigned_tasks(
370 Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
371 Extension(storage_factory): Extension<StorageLayerFactory>,
372) -> HttpStatusResult {
373 purge_expired_presigned_tasks(db_cache, storage_factory)
374 .await
375 .map_err(|_| HttpCommonError::ServerError)?;
376
377 Ok(StatusCode::NO_CONTENT)
378}
379
380#[utoipa::path(
384 post,
385 operation_id = "admin_list_users",
386 tag = ADMIN_TAG,
387 path = "/admin/users",
388 responses(
389 (status = 200, description = "Listed users successfully", body = AdminSearchResultResponse),
390 (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
391 (status = 500, description = "Internal server error", body = HttpErrorResponse)
392 ),
393 params(TenantParams)
394)]
395#[tracing::instrument(skip_all, fields(?req))]
396pub async fn list_users(
397 TenantDb(db): TenantDb,
398 Garde(Json(req)): Garde<Json<UsersRequest>>,
399) -> HttpResult<AdminUsersResults> {
400 let offset = req.offset.unwrap_or(0);
401 let limit = req.size.unwrap_or(100) as u64;
402
403 let total_future = User::total(&db);
404 let results_future = User::query(&db, offset, limit);
405 let (total, results) = try_join!(total_future, results_future).map_err(|error| {
406 tracing::error!(?error, "failed to query users");
407 HttpCommonError::ServerError
408 })?;
409
410 Ok(Json(AdminUsersResults { total, results }))
411}
412
413#[utoipa::path(
418 delete,
419 operation_id = "admin_delete_user",
420 tag = ADMIN_TAG,
421 path = "/admin/users/{id}",
422 responses(
423 (status = 204, description = "Listed users successfully", body = AdminSearchResultResponse),
424 (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
425 (status = 500, description = "Internal server error", body = HttpErrorResponse)
426 ),
427 params(TenantParams)
428)]
429#[tracing::instrument(skip_all, fields(id))]
430pub async fn delete_user(TenantDb(db): TenantDb, Path(id): Path<String>) -> HttpStatusResult {
431 let user = User::find(&db, &id)
432 .await
433 .map_err(|error| {
434 tracing::error!(?error, "failed to query user");
435 HttpCommonError::ServerError
436 })?
437 .ok_or(HttpAdminError::UnknownUser)?;
438
439 user.delete(&db).await.map_err(|error| {
440 if error.is_restrict() {
441 tracing::error!(
442 ?error,
443 "attempted to delete a user without first deleting attached resources"
444 );
445 DynHttpError::from(HttpAdminError::UserResourcesAttached)
446 } else {
447 tracing::error!(?error, "failed to delete user");
448 DynHttpError::from(HttpCommonError::ServerError)
449 }
450 })?;
451
452 Ok(StatusCode::NO_CONTENT)
453}