Skip to main content

headless_lms_server/controllers/helpers/
file_uploading.rs

1//! Helper functions related to uploading to file storage.
2
3pub use crate::domain::authorization::AuthorizationToken;
4use crate::prelude::*;
5use actix_http::header::HeaderMap;
6use actix_multipart as mp;
7use actix_multipart::Field;
8use actix_web::http::header;
9use futures::{StreamExt, TryStreamExt};
10use headless_lms_utils::file_store::{FileStore, GenericPayload};
11use headless_lms_utils::{
12    file_store::file_utils::get_extension_from_filename, strings::generate_random_string,
13};
14use models::organizations::DatabaseOrganization;
15use rand::distr::Alphanumeric;
16use rand::distr::SampleString;
17use std::{collections::HashSet, path::Path};
18use std::{
19    path::PathBuf,
20    sync::{
21        Arc, Mutex,
22        atomic::{AtomicU64, Ordering},
23    },
24};
25use utoipa::ToSchema;
26
27const EXERCISE_UPLOAD_MAX_FILES: usize = 10;
28const EXERCISE_UPLOAD_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
29const EXERCISE_UPLOAD_MAX_BATCH_BYTES: u64 = 100 * 1024 * 1024;
30
31/// What an upload route returns for one stored file: the `file_uploads` row id an answer names it
32/// by, and the URL it can be fetched from.
33#[derive(Debug, Clone, Serialize, ToSchema)]
34pub struct ExerciseServiceUploadResultEntry {
35    pub id: Uuid,
36    pub url: String,
37}
38
39/// One stored upload, with the file details the iframe's wire result omits and the client API's
40/// response carries.
41pub struct ExerciseServiceUpload {
42    pub entry: ExerciseServiceUploadResultEntry,
43    pub name: String,
44    pub mime: String,
45    pub size_bytes: i64,
46}
47
48/** Tracks uploaded object paths for cleanup when the batch fails. */
49pub struct ExerciseServiceUploadCleanup {
50    pub path: String,
51}
52
53/// Deletes the objects an upload has already stored, unless the upload got far enough to
54/// [`disarm`](Self::disarm) it.
55///
56/// Objects reach the store before their rows are committed, so an upload that fails part way has to
57/// delete them itself; the reaper only finds objects through the rows that were never written. On
58/// the handler's own error path call [`clean_up`](Self::clean_up) and await it. The `Drop` path is
59/// the backstop for the case that has no error path at all: the client aborting the multipart body,
60/// which drops the handler future at an await point.
61pub struct UploadCleanup {
62    pub uploaded_paths: Vec<ExerciseServiceUploadCleanup>,
63    file_store: web::Data<dyn FileStore>,
64    armed: bool,
65}
66
67impl UploadCleanup {
68    pub fn new(file_store: web::Data<dyn FileStore>) -> Self {
69        Self {
70            uploaded_paths: Vec::new(),
71            file_store,
72            armed: true,
73        }
74    }
75
76    /// Deletes what has been stored so far and disarms, so the `Drop` backstop cannot delete the
77    /// same objects again.
78    pub async fn clean_up(&mut self) {
79        self.armed = false;
80        for uploaded in std::mem::take(&mut self.uploaded_paths) {
81            if let Err(delete_error) = self.file_store.delete(Path::new(&uploaded.path)).await {
82                error!(
83                    "Failed to delete file '{}' during cleanup: {delete_error}",
84                    uploaded.path
85                );
86            }
87        }
88    }
89
90    /// Call only once the uploads are recorded, with no await in between: an await would give the
91    /// runtime a chance to drop the handler and delete objects that already have rows.
92    pub fn disarm(&mut self) {
93        self.armed = false;
94    }
95}
96
97impl Drop for UploadCleanup {
98    fn drop(&mut self) {
99        if !self.armed || self.uploaded_paths.is_empty() {
100            return;
101        }
102        let uploaded_paths = std::mem::take(&mut self.uploaded_paths);
103        let file_store = self.file_store.clone();
104        // `drop` cannot await, so the deletes run detached and outlive this request.
105        actix_web::rt::spawn(async move {
106            for uploaded in uploaded_paths {
107                if let Err(delete_error) = file_store.delete(Path::new(&uploaded.path)).await {
108                    error!(
109                        "Failed to delete file '{}' during cleanup: {delete_error}",
110                        uploaded.path
111                    );
112                }
113            }
114        });
115    }
116}
117
118struct ExerciseServiceUploadMetadata {
119    path: String,
120    filename: String,
121    mime_type: String,
122    size_bytes: i64,
123    url: String,
124}
125
126/// Processes an upload from an exercise service, an exercise iframe or a native client.
127/// This function assumes that any permission checks have already been made.
128///
129/// `exercise_service_slug` namespaces the stored objects and is recorded with the upload; it
130/// carries no authorization meaning. The playground passes its reserved `playground` slug, which
131/// names no exercise service.
132pub async fn process_exercise_service_upload(
133    conn: &mut PgConnection,
134    exercise_service_slug: &str,
135    payload: Multipart,
136    file_store: &dyn FileStore,
137    uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
138    uploader: Option<Uuid>,
139    base_url: &str,
140) -> Result<Vec<ExerciseServiceUpload>, ControllerError> {
141    let streamed = stream_exercise_service_upload(
142        exercise_service_slug,
143        payload,
144        file_store,
145        uploaded_paths,
146        base_url,
147    )
148    .await?;
149    let mut tx = conn.begin().await?;
150    let uploads = record_exercise_service_upload(&mut tx, streamed, uploader).await?;
151    let file_upload_ids: Vec<Uuid> = uploads.iter().map(|upload| upload.entry.id).collect();
152    // Recorded in the same transaction as the file rows: an upload the reaper cannot see is an
153    // upload nothing will ever reclaim, since a spec's references are invisible to the host.
154    models::exercise_spec_uploads::insert_many(
155        &mut tx,
156        exercise_service_slug,
157        uploader,
158        &file_upload_ids,
159    )
160    .await?;
161    tx.commit().await?;
162    Ok(uploads)
163}
164
165/// The parts of an upload that have reached the object store but have no database row yet.
166pub struct StreamedExerciseServiceUpload {
167    parts: Vec<ExerciseServiceUploadMetadata>,
168}
169
170/// Streams every multipart part to the object store, issuing no statements at all.
171///
172/// Split from the row inserts on purpose: the limits here are byte-based, not time-based, so a
173/// handler that opened a transaction first would hold a connection `idle in transaction` for as
174/// long as the client cares to trickle 100 MiB, and enough concurrent slow uploads would exhaust
175/// the pool and hold back the vacuum xmin horizon.
176pub async fn stream_exercise_service_upload(
177    path_prefix: &str,
178    mut payload: Multipart,
179    file_store: &dyn FileStore,
180    uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
181    base_url: &str,
182) -> Result<StreamedExerciseServiceUpload, ControllerError> {
183    let mut parts = Vec::new();
184    let mut ids = HashSet::new();
185    let batch_bytes = Arc::new(AtomicU64::new(0));
186    while let Some(item) = payload.next().await {
187        let field = item.map_err(|err| {
188            controller_err!(
189                BadRequest,
190                format!("Failed to read multipart field: {}", err),
191                anyhow::anyhow!("Multipart error: {}", err)
192            )
193        })?;
194        validate_exercise_upload_file_count(parts.len())?;
195        let field_name = {
196            let name_ref = field.name().ok_or_else(|| {
197                controller_err!(
198                    BadRequest,
199                    "Tried to upload a multipart field without a field name or field ID"
200                        .to_string()
201                )
202            })?;
203            name_ref.to_string()
204        };
205
206        validate_exercise_upload_id(&field_name, &mut ids)?;
207        let filename = validate_exercise_upload_filename(
208            field
209                .content_disposition()
210                .and_then(|disposition| disposition.get_filename()),
211        )?
212        .to_string();
213
214        let random_filename = generate_random_string(32);
215        let path = format!("{path_prefix}/{random_filename}");
216        uploaded_paths.push(ExerciseServiceUploadCleanup { path: path.clone() });
217        let mime_type = field
218            .content_type()
219            .map(ToString::to_string)
220            .unwrap_or_default();
221        let (stream, stream_error, uploaded_bytes) =
222            limited_exercise_upload_stream(field, batch_bytes.clone());
223        let upload_result = file_store
224            .upload_stream(Path::new(&path), stream, &mime_type)
225            .await;
226        if let Some(error) = stream_error
227            .lock()
228            .unwrap_or_else(|poisoned| poisoned.into_inner())
229            .take()
230        {
231            return Err(error);
232        }
233        upload_result?;
234        let url = format!("{base_url}/api/v0/files/{path}");
235        parts.push(ExerciseServiceUploadMetadata {
236            path,
237            filename,
238            mime_type,
239            size_bytes: uploaded_bytes.load(Ordering::SeqCst) as i64,
240            url,
241        });
242    }
243    validate_exercise_upload_not_empty(parts.len())?;
244    Ok(StreamedExerciseServiceUpload { parts })
245}
246
247/// Records the `file_uploads` rows for an already-streamed upload.
248///
249/// Takes the caller's transaction so that a caller which has further rows to write — the answer
250/// upload routes bind each upload to an exercise and user — lands them together with these.
251pub async fn record_exercise_service_upload(
252    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
253    streamed: StreamedExerciseServiceUpload,
254    uploader: Option<Uuid>,
255) -> Result<Vec<ExerciseServiceUpload>, ControllerError> {
256    let StreamedExerciseServiceUpload { parts } = streamed;
257    let mut uploads = Vec::with_capacity(parts.len());
258    for part in parts {
259        let file_upload_id = models::file_uploads::insert(
260            tx,
261            &part.filename,
262            &part.path,
263            &part.mime_type,
264            uploader,
265            Some(part.size_bytes),
266        )
267        .await?;
268        uploads.push(ExerciseServiceUpload {
269            entry: ExerciseServiceUploadResultEntry {
270                id: file_upload_id,
271                url: part.url,
272            },
273            name: part.filename,
274            mime: part.mime_type,
275            size_bytes: part.size_bytes,
276        });
277    }
278    Ok(uploads)
279}
280
281/// Returns a side channel for typed upload-limit errors because the stream yields `anyhow` errors,
282/// and the running byte count of this part. Both are only meaningful once `upload_stream` has
283/// returned: the count is still climbing while the stream is being consumed.
284fn limited_exercise_upload_stream(
285    field: mp::Field,
286    batch_bytes: Arc<AtomicU64>,
287) -> (
288    GenericPayload,
289    Arc<Mutex<Option<ControllerError>>>,
290    Arc<AtomicU64>,
291) {
292    let per_file_bytes = Arc::new(AtomicU64::new(0));
293    let stream_error = Arc::new(Mutex::new(None));
294    let payload_stream_error = stream_error.clone();
295    let payload_per_file_bytes = per_file_bytes.clone();
296    let stream = Box::pin(futures::stream::try_unfold(
297        (
298            field,
299            payload_per_file_bytes,
300            batch_bytes,
301            payload_stream_error,
302        ),
303        |(mut field, per_file_bytes, batch_bytes, stream_error)| async move {
304            let Some(chunk) = field.next().await else {
305                return Ok(None);
306            };
307            let chunk = chunk.map_err(|error| anyhow::Error::msg(error.to_string()))?;
308            if let Err(error) =
309                consume_exercise_upload_bytes(&per_file_bytes, &batch_bytes, chunk.len())
310            {
311                *stream_error
312                    .lock()
313                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(error);
314                return Err(anyhow::Error::msg("Exercise upload exceeds the size limit"));
315            }
316            Ok(Some((
317                chunk,
318                (field, per_file_bytes, batch_bytes, stream_error),
319            )))
320        },
321    ));
322    (stream, stream_error, per_file_bytes)
323}
324
325fn validate_exercise_upload_file_count(files_received: usize) -> Result<(), ControllerError> {
326    if files_received < EXERCISE_UPLOAD_MAX_FILES {
327        return Ok(());
328    }
329    Err(controller_err!(
330        BadRequest,
331        format!("A maximum of {EXERCISE_UPLOAD_MAX_FILES} files can be uploaded at once")
332    ))
333}
334
335fn validate_exercise_upload_id(
336    field_name: &str,
337    ids: &mut HashSet<String>,
338) -> Result<(), ControllerError> {
339    Uuid::parse_str(field_name).map_err(|_| {
340        controller_err!(
341            BadRequest,
342            "Each exercise upload field name must be a UUID".to_string()
343        )
344    })?;
345    if ids.insert(field_name.to_string()) {
346        return Ok(());
347    }
348    Err(controller_err!(
349        BadRequest,
350        "Duplicate exercise upload field id".to_string()
351    ))
352}
353
354fn validate_exercise_upload_filename(filename: Option<&str>) -> Result<&str, ControllerError> {
355    filename
356        .filter(|filename| !filename.is_empty())
357        .ok_or_else(|| {
358            controller_err!(
359                BadRequest,
360                "Every exercise upload part must be a file with a filename".to_string()
361            )
362        })
363}
364
365fn validate_exercise_upload_not_empty(files_received: usize) -> Result<(), ControllerError> {
366    if files_received > 0 {
367        return Ok(());
368    }
369    Err(controller_err!(
370        BadRequest,
371        "At least one file must be uploaded".to_string()
372    ))
373}
374
375fn consume_exercise_upload_bytes(
376    per_file_bytes: &AtomicU64,
377    batch_bytes: &AtomicU64,
378    chunk_len: usize,
379) -> Result<(), ControllerError> {
380    let chunk_len = u64::try_from(chunk_len).map_err(|error| {
381        controller_err!(
382            BadRequest,
383            "exercise upload chunk length overflow".to_string(),
384            error
385        )
386    })?;
387    let file_total = per_file_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
388    let batch_total = batch_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
389    if file_total <= EXERCISE_UPLOAD_MAX_FILE_BYTES
390        && batch_total <= EXERCISE_UPLOAD_MAX_BATCH_BYTES
391    {
392        return Ok(());
393    }
394    Err(controller_err!(
395        BadRequest,
396        "Exercise upload exceeds the 100 MiB per-file or batch limit"
397    ))
398}
399
400#[cfg(test)]
401mod exercise_upload_tests {
402    use super::*;
403
404    #[test]
405    fn exercise_upload_ids_must_be_unique_uuids() {
406        let id = Uuid::new_v4().to_string();
407        let mut ids = HashSet::new();
408
409        assert!(validate_exercise_upload_id(&id, &mut ids).is_ok());
410        assert!(
411            validate_exercise_upload_id(&id, &mut ids)
412                .unwrap_err()
413                .to_string()
414                .contains("Duplicate")
415        );
416        assert!(
417            validate_exercise_upload_id("filename.pdf", &mut ids)
418                .unwrap_err()
419                .to_string()
420                .contains("must be a UUID")
421        );
422    }
423
424    #[test]
425    fn exercise_upload_limits_are_enforced_from_streamed_bytes() {
426        let per_file = AtomicU64::new(EXERCISE_UPLOAD_MAX_FILE_BYTES - 1);
427        let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES - 1);
428        assert!(consume_exercise_upload_bytes(&per_file, &batch, 1).is_ok());
429        let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
430        assert!(matches!(
431            error.error_type(),
432            ControllerErrorType::BadRequest
433        ));
434
435        let per_file = AtomicU64::new(0);
436        let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES);
437        let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
438        assert!(matches!(
439            error.error_type(),
440            ControllerErrorType::BadRequest
441        ));
442    }
443
444    #[test]
445    fn exercise_upload_rejects_an_eleventh_file() {
446        assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES - 1).is_ok());
447        assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES).is_err());
448    }
449
450    #[test]
451    fn exercise_upload_rejects_empty_and_non_file_parts() {
452        assert!(validate_exercise_upload_not_empty(0).is_err());
453        assert!(validate_exercise_upload_not_empty(1).is_ok());
454        assert!(validate_exercise_upload_filename(None).is_err());
455        assert!(validate_exercise_upload_filename(Some("")).is_err());
456        assert_eq!(
457            validate_exercise_upload_filename(Some("report.pdf")).unwrap(),
458            "report.pdf"
459        );
460    }
461}
462
463#[derive(Debug, Clone, Copy, Deserialize)]
464
465pub enum StoreKind {
466    Organization(Uuid),
467    Course(Uuid),
468    Exam(Uuid),
469}
470
471/// Processes an upload from CMS.
472pub async fn upload_file_from_cms(
473    headers: &HeaderMap,
474    mut payload: Multipart,
475    store_kind: StoreKind,
476    file_store: &dyn FileStore,
477    conn: &mut PgConnection,
478    user: AuthUser,
479) -> Result<PathBuf, ControllerError> {
480    let file_payload = payload.next().await.ok_or_else(|| {
481        ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
482    })?;
483    match file_payload {
484        Ok(field) => {
485            upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
486        }
487        Err(err) => Err(ControllerError::new(
488            ControllerErrorType::InternalServerError,
489            err.to_string(),
490            None,
491        )),
492    }
493}
494
495/// Processes an upload from CMS.
496pub async fn upload_field_from_cms(
497    headers: &HeaderMap,
498    field: Field,
499    store_kind: StoreKind,
500    file_store: &dyn FileStore,
501    conn: &mut PgConnection,
502    user: AuthUser,
503) -> Result<PathBuf, ControllerError> {
504    validate_media_headers(headers, &user, conn).await?;
505    let path = match field.content_type().map(|ct| ct.type_()) {
506        Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
507        Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
508        _ => generate_file_path(&field, store_kind)?,
509    };
510    upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
511    Ok(path)
512}
513
514/// Processes an upload for an organization's image.
515pub async fn upload_image_for_organization(
516    headers: &HeaderMap,
517    mut payload: Multipart,
518    organization: &DatabaseOrganization,
519    file_store: &Arc<dyn FileStore>,
520    user: AuthUser,
521    conn: &mut PgConnection,
522) -> Result<PathBuf, ControllerError> {
523    validate_media_headers(headers, &user, conn).await?;
524    let next_payload: Result<Field, mp::MultipartError> =
525        payload.next().await.ok_or_else(|| {
526            ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
527        })?;
528    match next_payload {
529        Ok(field) => {
530            let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
531                Some(mime::IMAGE) => {
532                    generate_image_path(&field, StoreKind::Organization(organization.id))
533                }
534                Some(unsupported) => Err(ControllerError::new(
535                    ControllerErrorType::BadRequest,
536                    format!("Unsupported image Mime type: {}", unsupported),
537                    None,
538                )),
539                None => Err(ControllerError::new(
540                    ControllerErrorType::BadRequest,
541                    "Missing image Mime type",
542                    None,
543                )),
544            }?;
545            upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
546            Ok(path)
547        }
548        Err(err) => Err(ControllerError::new(
549            ControllerErrorType::InternalServerError,
550            err.to_string(),
551            None,
552        )),
553    }
554}
555
556// These limits must match the limits in CMS/src/services/backend/media/uploadMediaToServer.ts
557// If you modify these, update the TypeScript file as well.
558// Note: The nginx ingress also has a limit on max request size (see kubernetes/base/ingress.yml)
559const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
560    // 10 MB for images
561    (mime::IMAGE, 10 * 1024 * 1024),
562    // 100 MB for audio
563    (mime::AUDIO, 100 * 1024 * 1024),
564    // 100 MB for video
565    (mime::VIDEO, 100 * 1024 * 1024),
566    // 25 MB for documents/other files
567    (mime::APPLICATION, 25 * 1024 * 1024),
568];
569// 10 MB default fallback
570const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
571
572fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
573    mime_type
574        .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
575        .map(|(_, size)| *size)
576        .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
577}
578
579/// Uploads the data from the multipart `field` to the given `path` in file storage.
580async fn upload_field_to_storage(
581    conn: &mut PgConnection,
582    path: &Path,
583    field: mp::Field,
584    file_store: &dyn FileStore,
585    uploader: Option<AuthUser>,
586) -> Result<(), ControllerError> {
587    // Check file size limit based on mime type
588    let mime_type = field.content_type().map(|ct| ct.type_());
589    let size_limit = get_size_limit_for_mime(mime_type);
590
591    // Get size from content disposition if available
592    // Note: This does not enforce the size of the file since the client can lie about the content length
593    if let Some(content_disposition) = field.content_disposition()
594        && let Some(size_str) = content_disposition
595            .parameters
596            .iter()
597            .find_map(|p| p.as_unknown("size"))
598        && let Ok(size) = size_str.parse::<u64>()
599        && size > size_limit as u64
600    {
601        return Err(ControllerError::new(
602            ControllerErrorType::BadRequest,
603            format!(
604                "File size {} exceeds limit of {} bytes for type {}",
605                size,
606                size_limit,
607                mime_type.map_or("unknown".to_string(), |m| m.to_string())
608            ),
609            None,
610        ));
611    }
612
613    // TODO: convert archives into a uniform format
614    let mime_type = field
615        .content_type()
616        .map(|ct| ct.to_string())
617        .unwrap_or_default();
618
619    let name = {
620        let name_ref = field.name().ok_or_else(|| {
621            ControllerError::new(
622                ControllerErrorType::BadRequest,
623                "Tried to upload a file without a file name".to_string(),
624                None,
625            )
626        })?;
627        name_ref.to_string()
628    };
629
630    let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
631
632    upload_file_to_storage(
633        conn,
634        path,
635        &name,
636        &mime_type,
637        contents,
638        file_store,
639        uploader.map(|u| u.id),
640    )
641    .await?;
642    Ok(())
643}
644pub async fn upload_certificate_svg(
645    conn: &mut PgConnection,
646    file_name: &str,
647    file: GenericPayload,
648    file_store: &dyn FileStore,
649    course_id: Uuid,
650    uploader: AuthUser,
651) -> Result<(Uuid, PathBuf), ControllerError> {
652    let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
653    let safe_path = make_filename_safe(&path);
654    let id = upload_file_to_storage(
655        conn,
656        &safe_path,
657        file_name,
658        "image/svg+xml",
659        file,
660        file_store,
661        Some(uploader.id),
662    )
663    .await?;
664    Ok((id, safe_path))
665}
666
667async fn upload_file_to_storage(
668    conn: &mut PgConnection,
669    path: &Path,
670    file_name: &str,
671    mime_type: &str,
672    file: GenericPayload,
673    file_store: &dyn FileStore,
674    uploader: Option<Uuid>,
675) -> Result<Uuid, ControllerError> {
676    let mut tx = conn.begin().await?;
677    let id = upload_file_to_storage_in_existing_transaction(
678        &mut tx, path, file_name, mime_type, file, file_store, uploader,
679    )
680    .await?;
681    tx.commit().await?;
682    Ok(id)
683}
684
685async fn upload_file_to_storage_in_existing_transaction(
686    conn: &mut PgConnection,
687    path: &Path,
688    file_name: &str,
689    mime_type: &str,
690    file: GenericPayload,
691    file_store: &dyn FileStore,
692    uploader: Option<Uuid>,
693) -> Result<Uuid, ControllerError> {
694    let path_string = path.to_str().context("invalid path")?.to_string();
695    let id = models::file_uploads::insert(conn, file_name, &path_string, mime_type, uploader, None)
696        .await?;
697    file_store.upload_stream(path, file, mime_type).await?;
698    Ok(id)
699}
700
701fn make_filename_safe(path: &PathBuf) -> PathBuf {
702    let mut path_buf = path.to_owned();
703    let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
704    path_buf.set_file_name(random_string);
705    if let Some(ext) = path.extension() {
706        // For convenience, we'll keep the original extension in most cases. We'll just filter out any potentially problematic characters.
707        let ext = ext
708            .to_str()
709            .unwrap_or("")
710            .chars()
711            .filter(|c| c.is_alphanumeric())
712            .collect::<String>();
713        path_buf.set_extension(ext);
714    }
715    path_buf
716}
717
718pub async fn delete_file_from_storage(
719    conn: &mut PgConnection,
720    id: Uuid,
721    file_store: &dyn FileStore,
722) -> Result<(), ControllerError> {
723    let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
724    file_store.delete(Path::new(&file_to_delete)).await?;
725    Ok(())
726}
727
728/// Generates a path for an audio file with the appropriate extension.
729fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
730    let extension = match field
731        .content_type()
732        .map(|ct| ct.to_string())
733        .unwrap_or_default()
734        .as_str()
735    {
736        "audio/aac" => ".aac",
737        "audio/mpeg" => ".mp3",
738        "audio/ogg" => ".oga",
739        "audio/opus" => ".opus",
740        "audio/wav" => ".wav",
741        "audio/webm" => ".weba",
742        "audio/midi" => ".mid",
743        "audio/x-midi" => ".mid",
744        unsupported => {
745            return Err(ControllerError::new(
746                ControllerErrorType::BadRequest,
747                format!("Unsupported audio Mime type: {}", unsupported),
748                None,
749            ));
750        }
751    };
752    let mut file_name = generate_random_string(30);
753    file_name.push_str(extension);
754    let path = path(&file_name, FileType::Audio, store_kind);
755    Ok(path)
756}
757
758/// Generates a path for a generic file with the appropriate extension based on its filename.
759fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
760    let field_content = field.content_disposition().ok_or_else(|| {
761        ControllerError::new(
762            ControllerErrorType::BadRequest,
763            "No content disposition in uploaded file".to_string(),
764            None,
765        )
766    })?;
767    let field_content_name = field_content.get_filename().ok_or_else(|| {
768        ControllerError::new(
769            ControllerErrorType::BadRequest,
770            "Missing file name in content-disposition",
771            None,
772        )
773    })?;
774
775    let mut file_name = generate_random_string(30);
776    let uploaded_file_extension = get_extension_from_filename(field_content_name);
777    if let Some(extension) = uploaded_file_extension {
778        file_name.push_str(format!(".{}", extension).as_str());
779    }
780
781    let path = path(&file_name, FileType::File, store_kind);
782    Ok(path)
783}
784
785/// Generates a path for an image file with the appropriate extension.
786fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
787    let extension = match field
788        .content_type()
789        .map(|ct| ct.to_string())
790        .unwrap_or_default()
791        .as_str()
792    {
793        "image/jpeg" => ".jpg",
794        "image/png" => ".png",
795        "image/svg+xml" => ".svg",
796        "image/tiff" => ".tif",
797        "image/bmp" => ".bmp",
798        "image/webp" => ".webp",
799        "image/gif" => ".gif",
800        unsupported => {
801            return Err(ControllerError::new(
802                ControllerErrorType::BadRequest,
803                format!("Unsupported image Mime type: {}", unsupported),
804                None,
805            ));
806        }
807    };
808
809    // using a random string for the image name because
810    // a) we don't want the filename to be user controllable
811    // b) we don't want the filename to be too easily guessable (so no uuid)
812    let mut file_name = generate_random_string(30);
813    file_name.push_str(extension);
814    let path = path(&file_name, FileType::Image, store_kind);
815    Ok(path)
816}
817
818/// Generates a path for an audio file with the appropriate extension.
819async fn validate_media_headers(
820    headers: &HeaderMap,
821    user: &AuthUser,
822    conn: &mut PgConnection,
823) -> ControllerResult<()> {
824    let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
825        ControllerError::new(
826            ControllerErrorType::BadRequest,
827            "Please provide a Content-Type header",
828            None,
829        )
830    })?;
831    let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
832
833    if !content_type_string.contains("multipart/form-data") {
834        return Err(ControllerError::new(
835            ControllerErrorType::BadRequest,
836            format!("Unsupported type: {}", content_type_string),
837            None,
838        ));
839    }
840
841    let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
842        ControllerError::new(
843            ControllerErrorType::BadRequest,
844            "Please provide a Content-Length in header",
845            None,
846        )
847    })?;
848    let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
849        .to_string()
850        .parse::<i32>()
851        .map_err(|original_err| {
852            ControllerError::new(
853                ControllerErrorType::InternalServerError,
854                original_err.to_string(),
855                Some(original_err.into()),
856            )
857        })?;
858
859    let mime_type = headers
860        .get("X-File-Type")
861        .map(|h| h.to_str().unwrap_or("application/octet-stream"))
862        .unwrap_or("application/octet-stream")
863        .split('/')
864        .next()
865        .map(|s| match s {
866            "image" => mime::IMAGE,
867            "audio" => mime::AUDIO,
868            "video" => mime::VIDEO,
869            "application" => mime::APPLICATION,
870            _ => mime::APPLICATION,
871        });
872    let size_limit = get_size_limit_for_mime(mime_type);
873
874    // Note: This does not enforce the size of the file since the client can lie about the content length
875    if content_length_number > size_limit {
876        return Err(ControllerError::new(
877            ControllerErrorType::BadRequest,
878            format!(
879                "File size {} exceeds limit of {} bytes for type {}",
880                content_length_number,
881                size_limit,
882                mime_type.map_or("unknown".to_string(), |m| m.to_string())
883            ),
884            None,
885        ));
886    }
887
888    let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
889    token.authorized_ok(())
890}
891
892enum FileType {
893    Image,
894    Audio,
895    File,
896}
897
898fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
899    let (base_dir, base_id) = match store_kind {
900        StoreKind::Organization(id) => ("organization", id),
901        StoreKind::Course(id) => ("course", id),
902        StoreKind::Exam(id) => ("exam", id),
903    };
904    let file_type_subdir = match file_type {
905        FileType::Image => "images",
906        FileType::Audio => "audios",
907        FileType::File => "files",
908    };
909    [base_dir, &base_id.to_string(), file_type_subdir, file_name]
910        .iter()
911        .collect()
912}