Skip to main content

docbox_http/routes/
file.rs

1//! File related endpoints
2
3use crate::{
4    error::{DynHttpError, HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
5    extensions::max_file_size::MaxFileSizeBytes,
6    middleware::{
7        action_user::{ActionUser, UserParams},
8        tenant::{TenantDb, TenantEvents, TenantParams, TenantSearch, TenantStorage},
9    },
10    models::{
11        document_box::DocumentBoxScope,
12        file::{
13            BinaryResponse, CreatePresignedRequest, FileResponse, FileUploadResponse,
14            GetPresignedRequest, HttpFileError, PresignedDownloadResponse, PresignedStatusResponse,
15            PresignedUploadResponse, RawFileQuery, UpdateFileRequest, UploadFileRequest,
16            UploadTaskResponse, UploadedFile,
17        },
18        folder::HttpFolderError,
19    },
20};
21use axum::{
22    Extension, Json,
23    body::Body,
24    extract::{Path, Query},
25    http::{HeaderValue, Response, StatusCode, header},
26};
27use axum_typed_multipart::TypedMultipart;
28use axum_valid::Garde;
29use docbox_core::{
30    database::models::{
31        edit_history::EditHistory,
32        file::{File, FileId, FileWithExtra},
33        folder::Folder,
34        generated_file::{GeneratedFile, GeneratedFileType},
35        presigned_upload_task::{PresignedTaskStatus, PresignedUploadTask, PresignedUploadTaskId},
36        tasks::TaskStatus,
37        user::User,
38    },
39    files::{
40        delete_file::delete_file,
41        update_file::{UpdateFile, UpdateFileError},
42        upload_file::{UploadFile, UploadedFileData, upload_file},
43        upload_file_presigned::{CreatePresigned, create_presigned_upload},
44    },
45    processing::{ProcessingConfig, ProcessingLayer},
46    search::models::{FileSearchRequest, FileSearchResultResponse},
47    tasks::background_task::background_task,
48    utils::file::get_file_name_ext,
49};
50use mime::Mime;
51use std::{str::FromStr, time::Duration};
52use tracing::Instrument;
53
54pub const FILE_TAG: &str = "File";
55
56/// Upload file
57///
58/// Uploads a new document to the provided document box folder.
59///
60/// If the asynchronous option is specified a task will be returned
61/// otherwise the completed file upload will be returned directly
62///
63/// In a browser environment its recommend to use the async option to
64/// prevent running into browser timeouts if the processing takes too long.
65///
66/// In a reverse proxy + browser situation prefer using the presigned file upload
67/// endpoint otherwise browsers may timeout while your server transfers the file
68///
69/// Synchronous uploads return [UploadedFile]
70/// Asynchronous uploads return [UploadTaskResponse]
71///
72/// This endpoint is not available in the serverless version of docbox, instead use
73/// the presigned endpoint /box/{scope}/file/presigned
74///
75#[utoipa::path(
76    post,
77    operation_id = "file_upload",
78    tag = FILE_TAG,
79    path = "/box/{scope}/file",
80    responses(
81        (status = 200, description = "Upload or task created successfully", body = FileUploadResponse),
82        (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
83        (status = 404, description = "Target folder could not be found", body = HttpErrorResponse),
84        (status = 409, description = "Fixed ID is already in use", body = HttpErrorResponse),
85        (status = 500, description = "Internal server error", body = HttpErrorResponse)
86    ),
87    request_body(content = UploadFileRequest, description = "Multipart upload", content_type = "multipart/form-data"),
88    params(
89        ("scope" = DocumentBoxScope, Path, description = "Scope to create the file within"),
90        TenantParams,
91        UserParams
92    )
93)]
94#[tracing::instrument(skip_all, fields(%scope))]
95#[allow(clippy::too_many_arguments)]
96pub async fn upload(
97    action_user: ActionUser,
98    TenantDb(db): TenantDb,
99    TenantSearch(search): TenantSearch,
100    TenantStorage(storage): TenantStorage,
101    TenantEvents(events): TenantEvents,
102    //
103    Extension(processing): Extension<ProcessingLayer>,
104    //
105    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
106    Garde(TypedMultipart(req)): Garde<TypedMultipart<UploadFileRequest>>,
107) -> HttpResult<FileUploadResponse> {
108    let folder = Folder::find_by_id(&db, &scope, req.folder_id)
109        .await
110        .map_err(|error| {
111            tracing::error!(?error, "failed to query folder");
112            HttpCommonError::ServerError
113        })?
114        .ok_or(HttpFolderError::UnknownTargetFolder)?;
115
116    if let Some(fixed_id) = req.fixed_id
117        && File::find(&db, &scope, fixed_id)
118            .await
119            .map_err(|error| {
120                tracing::error!(?error, "failed to check for duplicate files");
121                HttpCommonError::ServerError
122            })?
123            .is_some()
124    {
125        return Err(DynHttpError::from(HttpFileError::FileIdInUse));
126    }
127
128    let content_type = req.mime.or(req.file.metadata.content_type);
129
130    let mut mime = match content_type {
131        Some(value) => Mime::from_str(&value).map_err(|_| HttpFileError::InvalidMimeType)?,
132        // Fallback to default mime type when none is provided
133        None => mime::APPLICATION_OCTET_STREAM,
134    };
135
136    // Attempt to guess the file mime type when application/octet-stream is specified
137    // (Likely from old browsers)
138    if mime == mime::APPLICATION_OCTET_STREAM
139        && req.disable_mime_sniffing.is_none_or(|value| !value)
140    {
141        let guessed_mime = get_file_name_ext(&req.name).and_then(|ext| {
142            let guesses = mime_guess::from_ext(&ext);
143            guesses.first()
144        });
145
146        if let Some(guessed_mime) = guessed_mime {
147            mime = guessed_mime
148        }
149    }
150
151    // Parse task processing config
152    let processing_config: Option<ProcessingConfig> = match &req.processing_config {
153        Some(value) => match serde_json::from_str(value) {
154            Ok(value) => value,
155            Err(error) => {
156                tracing::error!(?error, "failed to deserialize processing config");
157                None
158            }
159        },
160        None => None,
161    };
162
163    // Update stored editing user data
164    let created_by = action_user.store_user(&db).await?;
165
166    // Create the upload configuration
167    let upload = UploadFile {
168        fixed_id: req.fixed_id,
169        parent_id: req.parent_id,
170        folder_id: folder.id,
171        document_box: folder.document_box.clone(),
172        name: req.name,
173        mime,
174        file_bytes: req.file.contents,
175        created_by: created_by.as_ref().map(|value| value.id.to_string()),
176        file_key: None,
177        processing_config,
178    };
179
180    // Handle synchronous request waiting for the task to complete before responding
181    if !req.asynchronous.unwrap_or_default() {
182        let data = upload_file(&db, &search, &storage, &processing, &events, upload)
183            .await
184            .map_err(|error| {
185                tracing::error!(?error, "failed to upload file");
186                HttpFileError::UploadFileError(error)
187            })?;
188        let result = map_uploaded_file(data, &created_by);
189        return Ok(Json(FileUploadResponse::Sync(Box::new(result))));
190    }
191
192    let span = tracing::Span::current();
193
194    // Spawn background task
195    let (task_id, created_at) = background_task(
196        db.clone(),
197        scope.clone(),
198        async move {
199            let result = upload_file(&db, &search, &storage, &processing, &events, upload)
200                .await
201                .map_err(|error| {
202                    tracing::error!(?error, "failed to upload file");
203                    DynHttpError::from(HttpFileError::UploadFileError(error))
204                })
205                // Map the response into the desired format
206                .map(|data| map_uploaded_file(data, &created_by))
207                // Serialize the response for storage
208                .and_then(|value| {
209                    serde_json::to_value(&value).map_err(|error| {
210                        tracing::error!(?error, "failed to serialize upload task outcome");
211                        DynHttpError::from(HttpCommonError::ServerError)
212                    })
213                });
214
215            match result {
216                Ok(value) => (TaskStatus::Completed, value),
217                Err(error) => (
218                    TaskStatus::Failed,
219                    serde_json::json!({ "error": error.to_string() }),
220                ),
221            }
222        }
223        // Ensure the logging span is passed onto the background task so that
224        // logging context continues
225        .instrument(span),
226    )
227    .await
228    .map_err(|error| {
229        tracing::error!(?error, "failed to create background task");
230        HttpCommonError::ServerError
231    })?;
232
233    Ok(Json(FileUploadResponse::Async(UploadTaskResponse {
234        task_id,
235        created_at,
236    })))
237}
238
239/// Map a [UploadedFileData] output from the core layer into the [UploadedFile]
240/// HTTP response format
241fn map_uploaded_file(data: UploadedFileData, created_by: &Option<User>) -> UploadedFile {
242    let UploadedFileData {
243        file,
244        generated,
245        additional_files,
246    } = data;
247
248    UploadedFile {
249        file: FileWithExtra {
250            file,
251            created_by: created_by.clone(),
252            last_modified_at: None,
253            last_modified_by: None,
254        },
255        generated,
256
257        // Map created file children
258        additional_files: additional_files
259            .into_iter()
260            .map(|data| map_uploaded_file(data, created_by))
261            .collect(),
262    }
263}
264
265/// Create presigned file upload
266///
267/// Creates a new "presigned" upload, where the file is uploaded
268/// directly to storage and processed asynchronously.
269///
270/// Use the task ID from the response to poll the file processing
271/// progress.
272#[utoipa::path(
273    post,
274    operation_id = "file_create_presigned",
275    tag = FILE_TAG,
276    path = "/box/{scope}/file/presigned",
277    responses(
278        (status = 201, description = "Created presigned upload successfully", body = PresignedUploadResponse),
279        (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
280        (status = 404, description = "Target folder could not be found", body = HttpErrorResponse),
281        (status = 500, description = "Internal server error", body = HttpErrorResponse)
282    ),
283    params(
284        ("scope" = DocumentBoxScope, Path, description = "Scope to create the file within"),
285        TenantParams,
286        UserParams
287    )
288)]
289#[tracing::instrument(skip_all, fields(%scope, ?req))]
290pub async fn create_presigned(
291    action_user: ActionUser,
292    Extension(MaxFileSizeBytes(max_file_size)): Extension<MaxFileSizeBytes>,
293    TenantDb(db): TenantDb,
294    TenantStorage(storage): TenantStorage,
295    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
296    Garde(Json(req)): Garde<Json<CreatePresignedRequest>>,
297) -> Result<(StatusCode, Json<PresignedUploadResponse>), DynHttpError> {
298    if req.size > max_file_size {
299        return Err(HttpFileError::FileTooLarge(req.size, max_file_size).into());
300    }
301
302    let folder = Folder::find_by_id(&db, &scope, req.folder_id)
303        .await
304        .map_err(|error| {
305            tracing::error!(?error, "failed to query folder");
306            HttpCommonError::ServerError
307        })?
308        .ok_or(HttpFolderError::UnknownTargetFolder)?;
309
310    // Update stored editing user data
311    let created_by = action_user.store_user(&db).await?;
312
313    let mut mime = req.mime.unwrap_or(mime::APPLICATION_OCTET_STREAM);
314
315    // Attempt to guess the file mime type when application/octet-stream is specified
316    // (Likely from old browsers)
317    if mime == mime::APPLICATION_OCTET_STREAM
318        && req.disable_mime_sniffing.is_none_or(|value| !value)
319    {
320        let guessed_mime = get_file_name_ext(&req.name).and_then(|ext| {
321            let guesses = mime_guess::from_ext(&ext);
322            guesses.first()
323        });
324
325        if let Some(guessed_mime) = guessed_mime {
326            mime = guessed_mime
327        }
328    }
329
330    let response = create_presigned_upload(
331        &db,
332        &storage,
333        CreatePresigned {
334            name: req.name,
335            document_box: scope,
336            folder,
337            size: req.size,
338            mime,
339            created_by: created_by.map(|user| user.id),
340            parent_id: req.parent_id,
341            processing_config: req.processing_config,
342        },
343    )
344    .await
345    .map_err(|error| {
346        tracing::error!(?error, "failed to create presigned upload");
347        HttpCommonError::ServerError
348    })?;
349
350    Ok((
351        StatusCode::CREATED,
352        Json(PresignedUploadResponse {
353            task_id: response.task_id,
354            method: response.method,
355            uri: response.uri,
356            headers: response.headers,
357        }),
358    ))
359}
360
361/// Get presigned file upload
362///
363/// Gets the current state of a presigned upload either pending or
364/// complete, when complete the uploaded file and generated files
365/// are returned
366#[utoipa::path(
367    get,
368    operation_id = "file_get_presigned",
369    tag = FILE_TAG,
370    path = "/box/{scope}/file/presigned/{task_id}",
371    responses(
372        (status = 200, description = "Obtained presigned upload successfully", body = PresignedStatusResponse),
373        (status = 404, description = "Presigned upload not found", body = HttpErrorResponse),
374        (status = 500, description = "Internal server error", body = HttpErrorResponse)
375    ),
376    params(
377        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
378        ("task_id" = Uuid, Path, description = "ID of the task to query"),
379        TenantParams
380    )
381)]
382#[tracing::instrument(skip_all, fields(%scope, %task_id))]
383pub async fn get_presigned(
384    TenantDb(db): TenantDb,
385    Path((scope, task_id)): Path<(DocumentBoxScope, PresignedUploadTaskId)>,
386) -> HttpResult<PresignedStatusResponse> {
387    let DocumentBoxScope(scope) = scope;
388
389    let task = PresignedUploadTask::find(&db, &scope, task_id)
390        .await
391        .map_err(|error| {
392            tracing::error!(?error, "failed to query presigned upload");
393            HttpCommonError::ServerError
394        })?
395        .ok_or(HttpFileError::UnknownTask)?;
396
397    let file_id = match task.status {
398        PresignedTaskStatus::Pending => return Ok(Json(PresignedStatusResponse::Pending)),
399        PresignedTaskStatus::Completed { file_id } => file_id,
400        PresignedTaskStatus::Failed { error } => {
401            return Ok(Json(PresignedStatusResponse::Failed { error }));
402        }
403    };
404
405    let file = File::find_with_extra(&db, &scope, file_id)
406        .await
407        .map_err(|error| {
408            tracing::error!(?error, "failed to query file");
409            HttpCommonError::ServerError
410        })?
411        .ok_or(HttpFileError::UnknownFile)?;
412
413    let generated = GeneratedFile::find_all(&db, file_id)
414        .await
415        .map_err(|error| {
416            tracing::error!(?error, "failed to query generated files");
417            HttpCommonError::ServerError
418        })?;
419
420    Ok(Json(PresignedStatusResponse::Complete { file, generated }))
421}
422
423/// Get file by ID
424///
425/// Gets a specific file details, metadata and associated
426/// generated files
427#[utoipa::path(
428    get,
429    operation_id = "file_get",
430    tag = FILE_TAG,
431    path = "/box/{scope}/file/{file_id}",
432    responses(
433        (status = 200, description = "Obtained file successfully", body = FileResponse),
434        (status = 404, description = "File not found", body = HttpErrorResponse),
435        (status = 500, description = "Internal server error", body = HttpErrorResponse)
436    ),
437    params(
438        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
439        ("file_id" = Uuid, Path, description = "ID of the file to query"),
440        TenantParams
441    )
442)]
443#[tracing::instrument(skip_all, fields(%scope, %file_id))]
444pub async fn get(
445    TenantDb(db): TenantDb,
446    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
447) -> HttpResult<FileResponse> {
448    let DocumentBoxScope(scope) = scope;
449    let file = File::find_with_extra(&db, &scope, file_id)
450        .await
451        .map_err(|error| {
452            tracing::error!(?error, "failed to query file");
453            HttpCommonError::ServerError
454        })?
455        .ok_or(HttpFileError::UnknownFile)?;
456
457    let generated = GeneratedFile::find_all(&db, file_id)
458        .await
459        .map_err(|error| {
460            tracing::error!(?error, "failed to query generated files");
461            HttpCommonError::ServerError
462        })?;
463
464    Ok(Json(FileResponse { file, generated }))
465}
466
467/// Get file children
468///
469/// Get all children for the provided file, this is things like
470/// attachments for processed emails
471#[utoipa::path(
472    get,
473    operation_id = "file_get_children",
474    tag = FILE_TAG,
475    path = "/box/{scope}/file/{file_id}/children",
476    responses(
477        (status = 200, description = "Obtained children successfully", body = [FileWithExtra]),
478        (status = 404, description = "File not found", body = HttpErrorResponse),
479        (status = 500, description = "Internal server error", body = HttpErrorResponse)
480    ),
481    params(
482        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
483        ("file_id" = Uuid, Path, description = "ID of the file to query"),
484        TenantParams
485    )
486)]
487#[tracing::instrument(skip_all, fields(%scope, %file_id))]
488pub async fn get_children(
489    TenantDb(db): TenantDb,
490    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
491) -> HttpResult<Vec<FileWithExtra>> {
492    let DocumentBoxScope(scope) = scope;
493
494    // Request the file first to ensure scoping rules
495    _ = File::find_with_extra(&db, &scope, file_id)
496        .await
497        .map_err(|error| {
498            tracing::error!(?error, "failed to query file");
499            HttpCommonError::ServerError
500        })?
501        .ok_or(HttpFileError::UnknownFile)?;
502
503    let files = File::find_by_parent_file_with_extra(&db, file_id)
504        .await
505        .map_err(|error| {
506            tracing::error!(?error, "failed to query file children");
507            HttpCommonError::ServerError
508        })?;
509
510    Ok(Json(files))
511}
512
513/// Get file edit history
514///
515/// Gets the edit history for the provided file
516#[utoipa::path(
517    get,
518    operation_id = "file_edit_history",
519    tag = FILE_TAG,
520    path = "/box/{scope}/file/{file_id}/edit-history",
521    responses(
522        (status = 200, description = "Obtained edit-history successfully", body = [EditHistory]),
523        (status = 404, description = "File not found", body = HttpErrorResponse),
524        (status = 500, description = "Internal server error", body = HttpErrorResponse)
525    ),
526    params(
527        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
528        ("file_id" = Uuid, Path, description = "ID of the file to query"),
529        TenantParams
530    )
531)]
532#[tracing::instrument(skip_all, fields(%scope, %file_id))]
533pub async fn get_edit_history(
534    TenantDb(db): TenantDb,
535    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
536) -> HttpResult<Vec<EditHistory>> {
537    let DocumentBoxScope(scope) = scope;
538
539    _ = File::find(&db, &scope, file_id)
540        .await
541        .map_err(|error| {
542            tracing::error!(?error, "failed to query file");
543            HttpCommonError::ServerError
544        })?
545        .ok_or(HttpFileError::UnknownFile)?;
546
547    let edit_history = EditHistory::all_by_file(&db, file_id)
548        .await
549        .map_err(|error| {
550            tracing::error!(?error, "failed to query file history");
551            HttpCommonError::ServerError
552        })?;
553
554    Ok(Json(edit_history))
555}
556
557/// Update file
558///
559/// Updates a file, can be a name change, a folder move, or both
560#[utoipa::path(
561    put,
562    operation_id = "file_update",
563    tag = FILE_TAG,
564    path = "/box/{scope}/file/{file_id}",
565    responses(
566        (status = 200, description = "Obtained edit-history successfully", body = [EditHistory]),
567        (status = 404, description = "File not found", body = HttpErrorResponse),
568        (status = 500, description = "Internal server error", body = HttpErrorResponse)
569    ),
570    params(
571        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
572        ("file_id" = Uuid, Path, description = "ID of the file to query"),
573        TenantParams,
574        UserParams
575    )
576)]
577#[tracing::instrument(skip_all, fields(%scope, %file_id, ?req))]
578pub async fn update(
579    action_user: ActionUser,
580    TenantDb(db): TenantDb,
581    TenantSearch(search): TenantSearch,
582    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
583    Garde(Json(req)): Garde<Json<UpdateFileRequest>>,
584) -> HttpStatusResult {
585    let DocumentBoxScope(scope) = scope;
586
587    let file = File::find(&db, &scope, file_id)
588        .await
589        .map_err(|error| {
590            tracing::error!(?error, "failed to query file");
591            HttpCommonError::ServerError
592        })?
593        .ok_or(HttpFileError::UnknownFile)?;
594
595    // Update stored editing user data
596    let user = action_user.store_user(&db).await?;
597    let user_id = user.as_ref().map(|value| value.id.to_string());
598
599    let update = UpdateFile {
600        folder_id: req.folder_id,
601        name: req.name,
602        pinned: req.pinned,
603    };
604
605    docbox_core::files::update_file::update_file(&db, &search, &scope, file, user_id, update)
606        .await
607        .map_err(|error| match error {
608            UpdateFileError::UnknownTargetFolder => {
609                DynHttpError::from(HttpFolderError::UnknownTargetFolder)
610            }
611            _ => DynHttpError::from(HttpCommonError::ServerError),
612        })?;
613
614    Ok(StatusCode::OK)
615}
616
617/// Get file raw
618///
619/// Requests the raw contents of a file, this is used for downloading
620/// the file or viewing it in the browser or simply requesting its content
621#[utoipa::path(
622    get,
623    operation_id = "file_get_raw",
624    tag = FILE_TAG,
625    path = "/box/{scope}/file/{file_id}/raw",
626    responses(
627        (status = 200, description = "Obtained raw file successfully", content_type = "application/octet-stream", body = BinaryResponse),
628        (status = 404, description = "File not found", body = HttpErrorResponse),
629        (status = 500, description = "Internal server error", body = HttpErrorResponse)
630    ),
631    params(
632        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
633        ("file_id" = Uuid, Path, description = "ID of the file to query"),
634        TenantParams
635    )
636)]
637#[tracing::instrument(skip_all, fields(%scope, %file_id, ?query))]
638pub async fn get_raw(
639    TenantDb(db): TenantDb,
640    TenantStorage(storage): TenantStorage,
641    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
642    Query(query): Query<RawFileQuery>,
643) -> Result<Response<Body>, DynHttpError> {
644    let DocumentBoxScope(scope) = scope;
645
646    let file = File::find(&db, &scope, file_id)
647        .await
648        .map_err(|error| {
649            tracing::error!(?error, "failed to query file");
650            HttpCommonError::ServerError
651        })?
652        .ok_or(HttpFileError::UnknownFile)?;
653
654    let byte_stream = storage.get_file(&file.file_key).await.map_err(|error| {
655        tracing::error!(?error, "failed to get file from storage");
656        HttpCommonError::ServerError
657    })?;
658
659    let body = axum::body::Body::from_stream(byte_stream);
660
661    let ty = if query.download {
662        "attachment"
663    } else {
664        "inline"
665    };
666
667    let disposition = format!("{};filename=\"{}\"", ty, file.name);
668
669    let csp = match mime::Mime::from_str(&file.mime) {
670        // Images are served with a strict image only content security policy
671        Ok(mime) if mime.type_() == mime::IMAGE => {
672            "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
673        }
674        // Default policy
675        _ => "script-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'",
676    };
677
678    Ok(Response::builder()
679        .header(header::CONTENT_TYPE, file.mime)
680        .header(header::CONTENT_SECURITY_POLICY, csp)
681        .header(
682            header::CONTENT_DISPOSITION,
683            HeaderValue::from_str(&disposition)?,
684        )
685        .body(body)?)
686}
687
688/// Get file raw presigned
689///
690/// Requests the raw contents of a file as a presigned URL, used for
691/// letting the client directly download a file from AWS instead of
692/// downloading through the server
693#[utoipa::path(
694    post,
695    operation_id = "file_get_raw_presigned",
696    tag = FILE_TAG,
697    path = "/box/{scope}/file/{file_id}/raw-presigned",
698    responses(
699        (status = 200, description = "Obtained raw file successfully"),
700        (status = 404, description = "File not found", body = HttpErrorResponse),
701        (status = 500, description = "Internal server error", body = HttpErrorResponse)
702    ),
703    params(
704        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
705        ("file_id" = Uuid, Path, description = "ID of the file to download"),
706        TenantParams
707    )
708)]
709#[tracing::instrument(skip_all, fields(%scope, %file_id, ?req))]
710pub async fn get_raw_presigned(
711    TenantDb(db): TenantDb,
712    TenantStorage(storage): TenantStorage,
713    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
714    Json(req): Json<GetPresignedRequest>,
715) -> HttpResult<PresignedDownloadResponse> {
716    let DocumentBoxScope(scope) = scope;
717
718    let file = File::find(&db, &scope, file_id)
719        .await
720        .map_err(|error| {
721            tracing::error!(?error, "failed to query file");
722            HttpCommonError::ServerError
723        })?
724        .ok_or(HttpFileError::UnknownFile)?;
725
726    let expires_at = req.expires_at.unwrap_or(900);
727    let expires_at = Duration::from_secs(expires_at as u64);
728
729    let (signed_request, expires_at) = storage
730        .create_presigned_download(&file.file_key, expires_at)
731        .await
732        .map_err(|error| {
733            tracing::error!(?error, "failed to created file presigned download");
734            HttpCommonError::ServerError
735        })?;
736
737    Ok(Json(PresignedDownloadResponse {
738        method: signed_request.method().to_string(),
739        uri: signed_request.uri().to_string(),
740        headers: signed_request
741            .headers()
742            .map(|(key, value)| (key.to_string(), value.to_string()))
743            .collect(),
744        expires_at,
745    }))
746}
747
748/// Get file raw named
749///
750/// Requests the raw contents of a file, this is used for downloading
751/// the file or viewing it in the browser or simply requesting its content
752///
753/// This is identical to [get_raw] except it takes an additional catch-all
754/// tail parameter that's used to give a file name to the browser for things
755/// like the in-browser PDF viewers. Browsers (Chrome) don't always listen to the
756/// Content-Disposition file name so this is required
757#[utoipa::path(
758    get,
759    operation_id = "file_get_raw_named",
760    tag = FILE_TAG,
761    path = "/box/{scope}/file/{file_id}/raw/{file_name}",
762    responses(
763        (status = 200, description = "Obtained raw file successfully", content_type = "application/octet-stream", body = BinaryResponse),
764        (status = 404, description = "File not found", body = HttpErrorResponse),
765        (status = 500, description = "Internal server error", body = HttpErrorResponse)
766    ),
767    params(
768        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
769        ("file_id" = Uuid, Path, description = "ID of the file to query"),
770        ("file_name" = String, Path, description = "User defined file name for the download", allow_reserved = true),
771        TenantParams
772    )
773)]
774#[tracing::instrument(skip_all, fields(%scope, %file_id, ?query))]
775pub async fn get_raw_named(
776    db: TenantDb,
777    storage: TenantStorage,
778    Path((scope, file_id, _tail)): Path<(DocumentBoxScope, FileId, String)>,
779    query: Query<RawFileQuery>,
780) -> Result<Response<Body>, DynHttpError> {
781    get_raw(db, storage, Path((scope, file_id)), query).await
782}
783
784/// Search
785///
786/// Search within the contents of the file
787#[utoipa::path(
788    post,
789    operation_id = "file_search",
790    tag = FILE_TAG,
791    path = "/box/{scope}/file/{file_id}/search",
792    responses(
793        (status = 200, description = "Searched successfully", body = FileSearchResultResponse),
794        (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
795        (status = 404, description = "File not found", body = HttpErrorResponse),
796        (status = 500, description = "Internal server error", body = HttpErrorResponse)
797    ),
798    params(
799        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
800        ("file_id" = Uuid, Path, description = "ID of the file to query"),
801        TenantParams
802    )
803)]
804#[tracing::instrument(skip_all, fields(%scope, %file_id, ?req))]
805pub async fn search(
806    TenantDb(db): TenantDb,
807    TenantSearch(search): TenantSearch,
808    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
809    Json(req): Json<FileSearchRequest>,
810) -> HttpResult<FileSearchResultResponse> {
811    let DocumentBoxScope(scope) = scope;
812
813    // Assert the file exists
814    _ = File::find(&db, &scope, file_id)
815        .await
816        .map_err(|error| {
817            tracing::error!(?error, "failed to query file");
818            HttpCommonError::ServerError
819        })?
820        .ok_or(HttpFileError::UnknownFile)?;
821
822    let result = search
823        .search_index_file(&scope, file_id, req)
824        .await
825        .map_err(|error| {
826            tracing::error!(?error, "failed to search document box");
827            HttpCommonError::ServerError
828        })?;
829
830    Ok(Json(FileSearchResultResponse {
831        total_hits: result.total_hits,
832        results: result.results,
833    }))
834}
835
836/// Delete file by ID
837///
838/// Deletes the provided file
839#[utoipa::path(
840    delete,
841    operation_id = "file_delete",
842    tag = FILE_TAG,
843    path = "/box/{scope}/file/{file_id}",
844    responses(
845        (status = 204, description = "Deleted file successfully"),
846        (status = 404, description = "File not found", body = HttpErrorResponse),
847        (status = 500, description = "Internal server error", body = HttpErrorResponse)
848    ),
849    params(
850        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
851        ("file_id" = Uuid, Path, description = "ID of the file to delete"),
852        TenantParams
853    )
854)]
855#[tracing::instrument(skip_all, fields(%scope, %file_id))]
856pub async fn delete(
857    TenantDb(db): TenantDb,
858    TenantStorage(storage): TenantStorage,
859    TenantSearch(search): TenantSearch,
860    TenantEvents(events): TenantEvents,
861    Path((scope, file_id)): Path<(DocumentBoxScope, FileId)>,
862) -> HttpStatusResult {
863    let DocumentBoxScope(scope) = scope;
864
865    let file = File::find(&db, &scope, file_id)
866        .await
867        .map_err(|error| {
868            tracing::error!(?error, "failed to query file");
869            HttpCommonError::ServerError
870        })?
871        .ok_or(HttpFileError::UnknownFile)?;
872
873    delete_file(&db, &storage, &search, &events, file, scope)
874        .await
875        .map_err(|error| {
876            tracing::error!(?error, "failed to delete file");
877            HttpCommonError::ServerError
878        })?;
879
880    Ok(StatusCode::NO_CONTENT)
881}
882
883/// Get generated file
884///
885/// Requests metadata about a specific generated file type for
886/// a file, will return the details about the generated file
887/// if it exists
888#[utoipa::path(
889    get,
890    operation_id = "file_get_generated",
891    tag = FILE_TAG,
892    path = "/box/{scope}/file/{file_id}/generated/{type}",
893    responses(
894        (status = 200, description = "Obtained generated file successfully", body = GeneratedFile),
895        (status = 404, description = "Generated file not found", body = HttpErrorResponse),
896        (status = 500, description = "Internal server error", body = HttpErrorResponse)
897    ),
898    params(
899        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
900        ("file_id" = Uuid, Path, description = "ID of the file to query"),
901        ("type" = GeneratedFileType, Path, description = "ID of the file to query"),
902        TenantParams
903    )
904)]
905#[tracing::instrument(skip_all, fields(%scope, %file_id, %generated_type))]
906pub async fn get_generated(
907    TenantDb(db): TenantDb,
908    Path((scope, file_id, generated_type)): Path<(DocumentBoxScope, FileId, GeneratedFileType)>,
909) -> HttpResult<GeneratedFile> {
910    let DocumentBoxScope(scope) = scope;
911
912    let file = GeneratedFile::find(&db, &scope, file_id, generated_type)
913        .await
914        .map_err(|error| {
915            tracing::error!(?error, "failed to query generated file");
916            HttpCommonError::ServerError
917        })?
918        .ok_or(HttpFileError::NoMatchingGenerated)?;
919
920    Ok(Json(file))
921}
922
923/// Get generated file raw
924///
925/// Request the contents of a specific generated file type
926/// for a file, will return the file contents
927#[utoipa::path(
928    get,
929    operation_id = "file_get_generated_raw",
930    tag = FILE_TAG,
931    path = "/box/{scope}/file/{file_id}/generated/{type}/raw",
932    responses(
933        (status = 200, description = "Obtained raw file successfully", content_type = "application/octet-stream", body = BinaryResponse),
934        (status = 404, description = "Generated file not found", body = HttpErrorResponse),
935        (status = 500, description = "Internal server error", body = HttpErrorResponse)
936    ),
937    params(
938        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
939        ("file_id" = Uuid, Path, description = "ID of the file to query"),
940        ("type" = GeneratedFileType, Path, description = "ID of the file to query"),
941        TenantParams
942    )
943)]
944#[tracing::instrument(skip_all, fields(%scope, %file_id, %generated_type))]
945pub async fn get_generated_raw(
946    TenantDb(db): TenantDb,
947    TenantStorage(storage): TenantStorage,
948    Path((scope, file_id, generated_type)): Path<(DocumentBoxScope, FileId, GeneratedFileType)>,
949) -> Result<Response<Body>, DynHttpError> {
950    let DocumentBoxScope(scope) = scope;
951
952    let file = GeneratedFile::find(&db, &scope, file_id, generated_type)
953        .await
954        .map_err(|error| {
955            tracing::error!(?error, "failed to query generated file");
956            HttpCommonError::ServerError
957        })?
958        .ok_or(HttpFileError::NoMatchingGenerated)?;
959
960    let byte_stream = storage.get_file(&file.file_key).await.map_err(|error| {
961        tracing::error!(?error, "failed to file from storage");
962        HttpCommonError::ServerError
963    })?;
964
965    let body = axum::body::Body::from_stream(byte_stream);
966
967    let csp = match mime::Mime::from_str(&file.mime) {
968        // Images are served with a strict image only content security policy
969        Ok(mime) if mime.type_() == mime::IMAGE => "default-src 'none'; img-src 'self' data:;",
970        // Default policy
971        _ => "script-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'",
972    };
973
974    Ok(Response::builder()
975        .header(header::CONTENT_TYPE, file.mime)
976        .header(header::CONTENT_SECURITY_POLICY, csp)
977        .header(
978            header::CONTENT_DISPOSITION,
979            HeaderValue::from_str("inline;filename=\"preview.pdf\"")?,
980        )
981        .body(body)?)
982}
983
984/// Get generated file raw presigned
985///
986/// Requests the raw contents of a generated file as a presigned URL,
987/// used for letting the client directly download a file from AWS
988/// instead of downloading through the server and gateway
989#[utoipa::path(
990    post,
991    operation_id = "file_get_generated_raw_presigned",
992    tag = FILE_TAG,
993    path = "/box/{scope}/file/{file_id}/generated/{type}/raw-presigned",
994    responses(
995        (status = 200, description = "Obtained raw file successfully"),
996        (status = 404, description = "Generated file not found", body = HttpErrorResponse),
997        (status = 500, description = "Internal server error", body = HttpErrorResponse)
998    ),
999    params(
1000        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
1001        ("file_id" = Uuid, Path, description = "ID of the file to query"),
1002        ("type" = GeneratedFileType, Path, description = "ID of the file to query"),
1003        TenantParams
1004    )
1005)]
1006#[tracing::instrument(skip_all, fields(%scope, %file_id, %generated_type, ?req))]
1007pub async fn get_generated_raw_presigned(
1008    TenantDb(db): TenantDb,
1009    TenantStorage(storage): TenantStorage,
1010    Path((scope, file_id, generated_type)): Path<(DocumentBoxScope, FileId, GeneratedFileType)>,
1011    Json(req): Json<GetPresignedRequest>,
1012) -> HttpResult<PresignedDownloadResponse> {
1013    let DocumentBoxScope(scope) = scope;
1014
1015    let file = GeneratedFile::find(&db, &scope, file_id, generated_type)
1016        .await
1017        .map_err(|error| {
1018            tracing::error!(?error, "failed to query generated file");
1019            HttpCommonError::ServerError
1020        })?
1021        .ok_or(HttpFileError::NoMatchingGenerated)?;
1022
1023    let expires_at = req.expires_at.unwrap_or(900);
1024    let expires_at = Duration::from_secs(expires_at as u64);
1025
1026    let (signed_request, expires_at) = storage
1027        .create_presigned_download(&file.file_key, expires_at)
1028        .await
1029        .map_err(|_| HttpCommonError::ServerError)?;
1030
1031    Ok(Json(PresignedDownloadResponse {
1032        method: signed_request.method().to_string(),
1033        uri: signed_request.uri().to_string(),
1034        headers: signed_request
1035            .headers()
1036            .map(|(key, value)| (key.to_string(), value.to_string()))
1037            .collect(),
1038        expires_at,
1039    }))
1040}
1041
1042/// Get generated file raw named
1043///
1044/// Request the contents of a specific generated file type
1045/// for a file, will return the file contents
1046///
1047/// See [get_raw_named] for reasoning
1048#[utoipa::path(
1049    get,
1050    operation_id = "file_get_generated_raw_named",
1051    tag = FILE_TAG,
1052    path = "/box/{scope}/file/{file_id}/generated/{type}/raw/{file_name}",
1053    responses(
1054        (status = 200, description = "Obtained raw file successfully", content_type = "application/octet-stream", body = BinaryResponse),
1055        (status = 404, description = "Generated file not found", body = HttpErrorResponse),
1056        (status = 500, description = "Internal server error", body = HttpErrorResponse)
1057    ),
1058    params(
1059        ("scope" = DocumentBoxScope, Path, description = "Scope the file resides within"),
1060        ("file_id" = Uuid, Path, description = "ID of the file to query"),
1061        ("type" = GeneratedFileType, Path, description = "ID of the file to query"),
1062        ("file_name" = String, Path, description = "User defined file name for the download", allow_reserved = true),
1063        TenantParams
1064    )
1065)]
1066#[tracing::instrument(skip_all, fields(%scope, %file_id, %generated_type))]
1067pub async fn get_generated_raw_named(
1068    db: TenantDb,
1069    storage: TenantStorage,
1070    Path((scope, file_id, generated_type, _tail)): Path<(
1071        DocumentBoxScope,
1072        FileId,
1073        GeneratedFileType,
1074        String,
1075    )>,
1076) -> Result<Response<Body>, DynHttpError> {
1077    get_generated_raw(db, storage, Path((scope, file_id, generated_type))).await
1078}