Skip to main content

docbox_http/routes/
admin.rs

1//! Admin related access and routes for managing tenants and document boxes
2
3use 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/// Admin Boxes
41///
42/// Requests a list of document boxes within the tenant optionally filtered to
43/// a specific query with support for wildcards
44#[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            // Adjust the query to be better suited for searching
67            let mut query = query
68                // Replace wildcards with the SQL wildcard version
69                .replace("*", "%")
70                // Escape underscore literal
71                .replace("_", "\\_");
72
73            let has_wildcard = query.chars().any(|char| matches!(char, '*' | '%'));
74
75            // Query contains no wildcards, insert a wildcard at the end for prefix matching
76            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/// Admin Stats
120///
121/// Requests stats about a tenant such as the total of each item type as
122/// well as the total file size consumed
123#[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/// Admin Search
177///
178/// Performs a search across multiple document box scopes. This
179/// is an administrator route as unlike other routes we cannot
180/// assert through the URL that the user has access to all the
181/// scopes
182#[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    // Not searching any scopes
201    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/// Reprocess octet-stream files
239///
240/// Useful if a files were previously accepted into the tenant with some unknown
241/// file type (or ingested through a source that was unable to get the correct mime).
242///
243/// Will reprocess files that have this unknown file type mime to see if a different
244/// type can be obtained.
245///
246/// This endpoint is not supported on serverless
247#[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/// Rebuild search index
281///
282/// Rebuild the tenant search index from the data stored in the database
283/// and in storage
284///
285/// This endpoint is not supported on serverless
286#[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/// Flush database cache
314///
315/// Empties all the database pool and credentials caches, you can use this endpoint
316/// if you rotate your database credentials to refresh the database pool without
317/// needing to restart the server
318#[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/// Flush tenant cache
335///
336/// Clears the tenant cache, you can use this endpoint if you've updated the
337/// tenant configuration and want it to be applied immediately without
338/// restarting the server
339#[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/// Purge Presigned Tasks
356///
357/// Purges all expired presigned tasks, this operation deletes any presigned uploads
358/// that have not yet been completed but have passed the expiration date
359#[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/// List Users
381///
382/// Request lists of users stored in the docbox database
383#[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/// Delete User
414///
415/// Delete a user by ID, the user must not be associated with any resources
416/// (Edit history or creation of resources)
417#[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}