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::{HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
5    middleware::tenant::{TenantDb, TenantParams, TenantSearch, TenantStorage},
6    models::admin::{TenantDocumentBoxesRequest, TenantDocumentBoxesResponse, TenantStatsResponse},
7};
8use axum::{Extension, Json, http::StatusCode};
9use axum_valid::Garde;
10use docbox_core::{
11    database::{
12        DatabasePoolCache,
13        models::{
14            document_box::{DocumentBox, WithScope},
15            file::File,
16            folder::Folder,
17            link::Link,
18        },
19    },
20    document_box::search_document_box::{ResolvedSearchResult, search_document_boxes_admin},
21    processing::ProcessingLayer,
22    purge::purge_expired_presigned_tasks::purge_expired_presigned_tasks,
23    search::models::{AdminSearchRequest, AdminSearchResultResponse, SearchResultItem},
24    storage::StorageLayerFactory,
25    tenant::tenant_cache::TenantCache,
26};
27use std::sync::Arc;
28use tokio::join;
29
30pub const ADMIN_TAG: &str = "Admin";
31
32/// Admin Boxes
33///
34/// Requests a list of document boxes within the tenant optionally filtered to
35/// a specific query with support for wildcards
36#[utoipa::path(
37    post,
38    operation_id = "admin_tenant_boxes",
39    tag = ADMIN_TAG,
40    path = "/admin/boxes",
41    responses(
42        (status = 201, description = "Searched successfully", body = TenantDocumentBoxesResponse),
43        (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
44        (status = 500, description = "Internal server error", body = HttpErrorResponse)
45    ),
46    params(TenantParams)
47)]
48#[tracing::instrument(skip_all, fields(?req))]
49pub async fn tenant_boxes(
50    TenantDb(db): TenantDb,
51    Garde(Json(req)): Garde<Json<TenantDocumentBoxesRequest>>,
52) -> HttpResult<TenantDocumentBoxesResponse> {
53    let offset = req.offset.unwrap_or(0);
54    let limit = req.size.unwrap_or(100);
55
56    let (document_boxes, total) = match req.query {
57        Some(query) if !query.is_empty() => {
58            // Adjust the query to be better suited for searching
59            let mut query = query
60                // Replace wildcards with the SQL wildcard version
61                .replace("*", "%")
62                // Escape underscore literal
63                .replace("_", "\\_");
64
65            let has_wildcard = query.chars().any(|char| matches!(char, '*' | '%'));
66
67            // Query contains no wildcards, insert a wildcard at the end for prefix matching
68            if !has_wildcard {
69                query.push('%');
70            }
71
72            let document_boxes = DocumentBox::search_query(&db, &query, offset, limit as u64)
73                .await
74                .map_err(|error| {
75                    tracing::error!(?error, "failed to query document boxes");
76                    HttpCommonError::ServerError
77                })?;
78
79            let total = DocumentBox::search_total(&db, &query)
80                .await
81                .map_err(|error| {
82                    tracing::error!(?error, "failed to query document boxes total");
83                    HttpCommonError::ServerError
84                })?;
85
86            (document_boxes, total)
87        }
88        _ => {
89            let document_boxes = DocumentBox::query(&db, offset, limit as u64)
90                .await
91                .map_err(|error| {
92                    tracing::error!(?error, "failed to query document boxes");
93                    HttpCommonError::ServerError
94                })?;
95
96            let total = DocumentBox::total(&db).await.map_err(|error| {
97                tracing::error!(?error, "failed to query document boxes total");
98                HttpCommonError::ServerError
99            })?;
100
101            (document_boxes, total)
102        }
103    };
104
105    Ok(Json(TenantDocumentBoxesResponse {
106        results: document_boxes,
107        total,
108    }))
109}
110
111/// Admin Stats
112///
113/// Requests stats about a tenant such as the total of each item type as
114/// well as the total file size consumed
115#[utoipa::path(
116    get,
117    operation_id = "admin_tenant_stats",
118    tag = ADMIN_TAG,
119    path = "/admin/tenant-stats",
120    responses(
121        (status = 201, description = "Got stats successfully", body = TenantStatsResponse),
122        (status = 500, description = "Internal server error", body = HttpErrorResponse)
123    ),
124    params(TenantParams)
125)]
126#[tracing::instrument(skip_all)]
127pub async fn tenant_stats(TenantDb(db): TenantDb) -> HttpResult<TenantStatsResponse> {
128    let total_files_future = File::total_count(&db);
129    let total_links_future = Link::total_count(&db);
130    let total_folders_future = Folder::total_count(&db);
131    let file_size_future = File::total_size(&db);
132
133    let (total_files, total_links, total_folders, file_size) = join!(
134        total_files_future,
135        total_links_future,
136        total_folders_future,
137        file_size_future
138    );
139
140    let total_files = total_files.map_err(|error| {
141        tracing::error!(?error, "failed to query tenant total files");
142        HttpCommonError::ServerError
143    })?;
144
145    let total_links = total_links.map_err(|error| {
146        tracing::error!(?error, "failed to query tenant total links");
147        HttpCommonError::ServerError
148    })?;
149
150    let total_folders = total_folders.map_err(|error| {
151        tracing::error!(?error, "failed to query tenant total folders");
152        HttpCommonError::ServerError
153    })?;
154
155    let file_size = file_size.map_err(|error| {
156        tracing::error!(?error, "failed to query tenant files size");
157        HttpCommonError::ServerError
158    })?;
159
160    Ok(Json(TenantStatsResponse {
161        total_files,
162        total_folders,
163        total_links,
164        file_size,
165    }))
166}
167
168/// Admin Search
169///
170/// Performs a search across multiple document box scopes. This
171/// is an administrator route as unlike other routes we cannot
172/// assert through the URL that the user has access to all the
173/// scopes
174#[utoipa::path(
175    post,
176    operation_id = "admin_search_tenant",
177    tag = ADMIN_TAG,
178    path = "/admin/search",
179    responses(
180        (status = 201, description = "Searched successfully", body = AdminSearchResultResponse),
181        (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
182        (status = 500, description = "Internal server error", body = HttpErrorResponse)
183    ),
184    params(TenantParams)
185)]
186#[tracing::instrument(skip_all, fields(?req))]
187pub async fn search_tenant(
188    TenantDb(db): TenantDb,
189    TenantSearch(search): TenantSearch,
190    Garde(Json(req)): Garde<Json<AdminSearchRequest>>,
191) -> HttpResult<AdminSearchResultResponse> {
192    // Not searching any scopes
193    if req.scopes.is_empty() {
194        return Ok(Json(AdminSearchResultResponse {
195            total_hits: 0,
196            results: vec![],
197        }));
198    }
199
200    let resolved = search_document_boxes_admin(&db, &search, req)
201        .await
202        .map_err(|error| {
203            tracing::error!(?error, "failed to perform admin search");
204            HttpCommonError::ServerError
205        })?;
206
207    let out: Vec<WithScope<SearchResultItem>> = resolved
208        .results
209        .into_iter()
210        .map(|ResolvedSearchResult { result, data, path }| WithScope {
211            data: SearchResultItem {
212                path,
213                score: result.score,
214                data,
215                page_matches: result.page_matches,
216                total_hits: result.total_hits,
217                name_match: result.name_match,
218                content_match: result.content_match,
219            },
220            scope: result.document_box,
221        })
222        .collect();
223
224    Ok(Json(AdminSearchResultResponse {
225        total_hits: resolved.total_hits,
226        results: out,
227    }))
228}
229
230/// Reprocess octet-stream files
231///
232/// Useful if a files were previously accepted into the tenant with some unknown
233/// file type (or ingested through a source that was unable to get the correct mime).
234///
235/// Will reprocess files that have this unknown file type mime to see if a different
236/// type can be obtained.
237///
238/// This endpoint is not supported on serverless
239#[utoipa::path(
240    post,
241    operation_id = "admin_reprocess_octet_stream_files",
242    tag = ADMIN_TAG,
243    path = "/admin/reprocess-octet-stream-files",
244    responses(
245        (status = 204, description = "Reprocessed successfully", body = AdminSearchResultResponse),
246        (status = 500, description = "Internal server error", body = HttpErrorResponse)
247    ),
248    params(TenantParams)
249)]
250#[tracing::instrument(skip_all)]
251pub async fn reprocess_octet_stream_files_tenant(
252    TenantDb(db): TenantDb,
253    TenantSearch(search): TenantSearch,
254    TenantStorage(storage): TenantStorage,
255    Extension(processing): Extension<ProcessingLayer>,
256) -> HttpStatusResult {
257    docbox_core::files::reprocess_octet_stream_files::reprocess_octet_stream_files(
258        &db,
259        &search,
260        &storage,
261        &processing,
262    )
263    .await
264    .map_err(|error| {
265        tracing::error!(?error, "failed to reprocess octet-stream files");
266        HttpCommonError::ServerError
267    })?;
268
269    Ok(StatusCode::NO_CONTENT)
270}
271
272/// Rebuild search index
273///
274/// Rebuild the tenant search index from the data stored in the database
275/// and in storage
276///
277/// This endpoint is not supported on serverless
278#[utoipa::path(
279    post,
280    operation_id = "admin_rebuild_search_index",
281    tag = ADMIN_TAG,
282    path = "/admin/rebuild-search-index",
283    responses(
284        (status = 204, description = "Rebuilt successfully", body = ()),
285        (status = 500, description = "Internal server error", body = HttpErrorResponse)
286    ),
287    params(TenantParams)
288)]
289#[tracing::instrument(skip_all)]
290pub async fn rebuild_search_index_tenant(
291    TenantDb(db): TenantDb,
292    TenantSearch(search): TenantSearch,
293    TenantStorage(storage): TenantStorage,
294) -> HttpStatusResult {
295    docbox_core::tenant::rebuild_tenant_index::rebuild_tenant_index(&db, &search, &storage)
296        .await
297        .map_err(|error| {
298            tracing::error!(?error, "failed to rebuilt tenant search index");
299            HttpCommonError::ServerError
300        })?;
301
302    Ok(StatusCode::NO_CONTENT)
303}
304
305/// Flush database cache
306///
307/// Empties all the database pool and credentials caches, you can use this endpoint
308/// if you rotate your database credentials to refresh the database pool without
309/// needing to restart the server
310#[utoipa::path(
311    post,
312    operation_id = "admin_flush_database_pool_cache",
313    tag = ADMIN_TAG,
314    path = "/admin/flush-db-cache",
315    responses(
316        (status = 204, description = "Database cache flushed"),
317    )
318)]
319pub async fn flush_database_pool_cache(
320    Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
321) -> HttpStatusResult {
322    db_cache.flush().await;
323    Ok(StatusCode::NO_CONTENT)
324}
325
326/// Flush tenant cache
327///
328/// Clears the tenant cache, you can use this endpoint if you've updated the
329/// tenant configuration and want it to be applied immediately without
330/// restarting the server
331#[utoipa::path(
332    post,
333    operation_id = "admin_flush_tenant_cache",
334    tag = ADMIN_TAG,
335    path = "/admin/flush-tenant-cache",
336    responses(
337        (status = 204, description = "Tenant cache flushed"),
338    )
339)]
340pub async fn flush_tenant_cache(
341    Extension(tenant_cache): Extension<Arc<TenantCache>>,
342) -> HttpStatusResult {
343    tenant_cache.flush().await;
344    Ok(StatusCode::NO_CONTENT)
345}
346
347/// Purge Presigned Tasks
348///
349/// Purges all expired presigned tasks, this operation deletes any presigned uploads
350/// that have not yet been completed but have passed the expiration date
351#[utoipa::path(
352    post,
353    operation_id = "admin_purge_expired_presigned_tasks",
354    tag = ADMIN_TAG,
355    path = "/admin/purge-expired-presigned-tasks",
356    responses(
357        (status = 204, description = "Database cache flushed"),
358        (status = 500, description = "Failed to purge presigned cache", body = HttpErrorResponse),
359    )
360)]
361pub async fn http_purge_expired_presigned_tasks(
362    Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
363    Extension(storage_factory): Extension<StorageLayerFactory>,
364) -> HttpStatusResult {
365    purge_expired_presigned_tasks(db_cache, storage_factory)
366        .await
367        .map_err(|_| HttpCommonError::ServerError)?;
368
369    Ok(StatusCode::NO_CONTENT)
370}