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