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 mime::Mime;
15use models::exercise_slides::ExerciseSlide;
16use models::exercise_tasks::ExerciseTask;
17use models::exercises::Exercise;
18use models::organizations::DatabaseOrganization;
19use rand::distr::Alphanumeric;
20use rand::distr::SampleString;
21use std::{collections::HashSet, path::Path};
22use std::{
23    path::PathBuf,
24    sync::{
25        Arc, Mutex,
26        atomic::{AtomicU64, Ordering},
27    },
28};
29use utoipa::ToSchema;
30
31const EXERCISE_UPLOAD_MAX_FILES: usize = 10;
32const EXERCISE_UPLOAD_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
33const EXERCISE_UPLOAD_MAX_BATCH_BYTES: u64 = 100 * 1024 * 1024;
34
35#[derive(Debug, Clone, Serialize, ToSchema)]
36pub struct ExerciseServiceUploadResultEntry {
37    pub id: String,
38    pub url: String,
39}
40
41/** Tracks uploaded object paths for cleanup when the batch fails. */
42pub struct ExerciseServiceUploadCleanup {
43    pub path: String,
44}
45
46struct ExerciseServiceUploadMetadata {
47    path: String,
48    filename: String,
49    mime_type: String,
50}
51
52/// Processes an upload from an exercise service or an exercise iframe.
53/// This function assumes that any permission checks have already been made.
54pub async fn process_exercise_service_upload(
55    conn: &mut PgConnection,
56    exercise_service_slug: &str,
57    mut payload: Multipart,
58    file_store: &dyn FileStore,
59    uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
60    uploader: Option<AuthUser>,
61    base_url: &str,
62) -> Result<Vec<ExerciseServiceUploadResultEntry>, ControllerError> {
63    let mut results = Vec::new();
64    let mut metadata = Vec::new();
65    let mut ids = HashSet::new();
66    let batch_bytes = Arc::new(AtomicU64::new(0));
67    while let Some(item) = payload.next().await {
68        let field = item.map_err(|err| {
69            controller_err!(
70                BadRequest,
71                format!("Failed to read multipart field: {}", err),
72                anyhow::anyhow!("Multipart error: {}", err)
73            )
74        })?;
75        validate_exercise_upload_file_count(results.len())?;
76        let field_name = {
77            let name_ref = field.name().ok_or_else(|| {
78                controller_err!(
79                    BadRequest,
80                    "Tried to upload a multipart field without a field name or field ID"
81                        .to_string()
82                )
83            })?;
84            name_ref.to_string()
85        };
86
87        validate_exercise_upload_id(&field_name, &mut ids)?;
88        let filename = validate_exercise_upload_filename(
89            field
90                .content_disposition()
91                .and_then(|disposition| disposition.get_filename()),
92        )?
93        .to_string();
94
95        let random_filename = generate_random_string(32);
96        let path = format!("{exercise_service_slug}/{random_filename}");
97        uploaded_paths.push(ExerciseServiceUploadCleanup { path: path.clone() });
98        let mime_type = field
99            .content_type()
100            .map(ToString::to_string)
101            .unwrap_or_default();
102        let (stream, stream_error) = limited_exercise_upload_stream(field, batch_bytes.clone());
103        let upload_result = file_store
104            .upload_stream(Path::new(&path), stream, &mime_type)
105            .await;
106        if let Some(error) = stream_error
107            .lock()
108            .unwrap_or_else(|poisoned| poisoned.into_inner())
109            .take()
110        {
111            return Err(error);
112        }
113        upload_result?;
114        let url = format!("{base_url}/api/v0/files/{path}");
115        results.push(ExerciseServiceUploadResultEntry {
116            id: field_name,
117            url,
118        });
119        metadata.push(ExerciseServiceUploadMetadata {
120            path,
121            filename,
122            mime_type,
123        });
124    }
125    validate_exercise_upload_not_empty(results.len())?;
126    let mut tx = conn.begin().await?;
127    for upload in metadata {
128        models::file_uploads::insert(
129            &mut tx,
130            &upload.filename,
131            &upload.path,
132            &upload.mime_type,
133            uploader.map(|user| user.id),
134        )
135        .await?;
136    }
137    tx.commit().await?;
138    Ok(results)
139}
140
141/// Returns a side channel for typed upload-limit errors because the stream yields `anyhow` errors.
142/// Callers must inspect it after `upload_stream` returns.
143fn limited_exercise_upload_stream(
144    field: mp::Field,
145    batch_bytes: Arc<AtomicU64>,
146) -> (GenericPayload, Arc<Mutex<Option<ControllerError>>>) {
147    let per_file_bytes = Arc::new(AtomicU64::new(0));
148    let stream_error = Arc::new(Mutex::new(None));
149    let payload_stream_error = stream_error.clone();
150    let stream = Box::pin(futures::stream::try_unfold(
151        (field, per_file_bytes, batch_bytes, payload_stream_error),
152        |(mut field, per_file_bytes, batch_bytes, stream_error)| async move {
153            let Some(chunk) = field.next().await else {
154                return Ok(None);
155            };
156            let chunk = chunk.map_err(|error| anyhow::Error::msg(error.to_string()))?;
157            if let Err(error) =
158                consume_exercise_upload_bytes(&per_file_bytes, &batch_bytes, chunk.len())
159            {
160                *stream_error
161                    .lock()
162                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(error);
163                return Err(anyhow::Error::msg("Exercise upload exceeds the size limit"));
164            }
165            Ok(Some((
166                chunk,
167                (field, per_file_bytes, batch_bytes, stream_error),
168            )))
169        },
170    ));
171    (stream, stream_error)
172}
173
174fn validate_exercise_upload_file_count(files_received: usize) -> Result<(), ControllerError> {
175    if files_received < EXERCISE_UPLOAD_MAX_FILES {
176        return Ok(());
177    }
178    Err(controller_err!(
179        BadRequest,
180        format!("A maximum of {EXERCISE_UPLOAD_MAX_FILES} files can be uploaded at once")
181    ))
182}
183
184fn validate_exercise_upload_id(
185    field_name: &str,
186    ids: &mut HashSet<String>,
187) -> Result<(), ControllerError> {
188    Uuid::parse_str(field_name).map_err(|_| {
189        controller_err!(
190            BadRequest,
191            "Each exercise upload field name must be a UUID".to_string()
192        )
193    })?;
194    if ids.insert(field_name.to_string()) {
195        return Ok(());
196    }
197    Err(controller_err!(
198        BadRequest,
199        "Duplicate exercise upload field id".to_string()
200    ))
201}
202
203fn validate_exercise_upload_filename(filename: Option<&str>) -> Result<&str, ControllerError> {
204    filename
205        .filter(|filename| !filename.is_empty())
206        .ok_or_else(|| {
207            controller_err!(
208                BadRequest,
209                "Every exercise upload part must be a file with a filename".to_string()
210            )
211        })
212}
213
214fn validate_exercise_upload_not_empty(files_received: usize) -> Result<(), ControllerError> {
215    if files_received > 0 {
216        return Ok(());
217    }
218    Err(controller_err!(
219        BadRequest,
220        "At least one file must be uploaded".to_string()
221    ))
222}
223
224fn consume_exercise_upload_bytes(
225    per_file_bytes: &AtomicU64,
226    batch_bytes: &AtomicU64,
227    chunk_len: usize,
228) -> Result<(), ControllerError> {
229    let chunk_len = u64::try_from(chunk_len).map_err(|error| {
230        controller_err!(
231            BadRequest,
232            "exercise upload chunk length overflow".to_string(),
233            error
234        )
235    })?;
236    let file_total = per_file_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
237    let batch_total = batch_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
238    if file_total <= EXERCISE_UPLOAD_MAX_FILE_BYTES
239        && batch_total <= EXERCISE_UPLOAD_MAX_BATCH_BYTES
240    {
241        return Ok(());
242    }
243    Err(controller_err!(
244        BadRequest,
245        "Exercise upload exceeds the 100 MiB per-file or batch limit"
246    ))
247}
248
249#[cfg(test)]
250mod exercise_upload_tests {
251    use super::*;
252
253    #[test]
254    fn exercise_upload_ids_must_be_unique_uuids() {
255        let id = Uuid::new_v4().to_string();
256        let mut ids = HashSet::new();
257
258        assert!(validate_exercise_upload_id(&id, &mut ids).is_ok());
259        assert!(
260            validate_exercise_upload_id(&id, &mut ids)
261                .unwrap_err()
262                .to_string()
263                .contains("Duplicate")
264        );
265        assert!(
266            validate_exercise_upload_id("filename.pdf", &mut ids)
267                .unwrap_err()
268                .to_string()
269                .contains("must be a UUID")
270        );
271    }
272
273    #[test]
274    fn exercise_upload_limits_are_enforced_from_streamed_bytes() {
275        let per_file = AtomicU64::new(EXERCISE_UPLOAD_MAX_FILE_BYTES - 1);
276        let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES - 1);
277        assert!(consume_exercise_upload_bytes(&per_file, &batch, 1).is_ok());
278        let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
279        assert!(matches!(
280            error.error_type(),
281            ControllerErrorType::BadRequest
282        ));
283
284        let per_file = AtomicU64::new(0);
285        let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES);
286        let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
287        assert!(matches!(
288            error.error_type(),
289            ControllerErrorType::BadRequest
290        ));
291    }
292
293    #[test]
294    fn exercise_upload_rejects_an_eleventh_file() {
295        assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES - 1).is_ok());
296        assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES).is_err());
297    }
298
299    #[test]
300    fn exercise_upload_rejects_empty_and_non_file_parts() {
301        assert!(validate_exercise_upload_not_empty(0).is_err());
302        assert!(validate_exercise_upload_not_empty(1).is_ok());
303        assert!(validate_exercise_upload_filename(None).is_err());
304        assert!(validate_exercise_upload_filename(Some("")).is_err());
305        assert_eq!(
306            validate_exercise_upload_filename(Some("report.pdf")).unwrap(),
307            "report.pdf"
308        );
309    }
310}
311
312#[derive(Debug, Clone, Copy, Deserialize)]
313
314pub enum StoreKind {
315    Organization(Uuid),
316    Course(Uuid),
317    Exam(Uuid),
318}
319
320/// Processes an upload from CMS.
321pub async fn upload_file_from_cms(
322    headers: &HeaderMap,
323    mut payload: Multipart,
324    store_kind: StoreKind,
325    file_store: &dyn FileStore,
326    conn: &mut PgConnection,
327    user: AuthUser,
328) -> Result<PathBuf, ControllerError> {
329    let file_payload = payload.next().await.ok_or_else(|| {
330        ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
331    })?;
332    match file_payload {
333        Ok(field) => {
334            upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
335        }
336        Err(err) => Err(ControllerError::new(
337            ControllerErrorType::InternalServerError,
338            err.to_string(),
339            None,
340        )),
341    }
342}
343
344/// Processes an upload from CMS.
345pub async fn upload_field_from_cms(
346    headers: &HeaderMap,
347    field: Field,
348    store_kind: StoreKind,
349    file_store: &dyn FileStore,
350    conn: &mut PgConnection,
351    user: AuthUser,
352) -> Result<PathBuf, ControllerError> {
353    validate_media_headers(headers, &user, conn).await?;
354    let path = match field.content_type().map(|ct| ct.type_()) {
355        Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
356        Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
357        _ => generate_file_path(&field, store_kind)?,
358    };
359    upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
360    Ok(path)
361}
362
363/// Processes an upload for an organization's image.
364pub async fn upload_image_for_organization(
365    headers: &HeaderMap,
366    mut payload: Multipart,
367    organization: &DatabaseOrganization,
368    file_store: &Arc<dyn FileStore>,
369    user: AuthUser,
370    conn: &mut PgConnection,
371) -> Result<PathBuf, ControllerError> {
372    validate_media_headers(headers, &user, conn).await?;
373    let next_payload: Result<Field, mp::MultipartError> =
374        payload.next().await.ok_or_else(|| {
375            ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
376        })?;
377    match next_payload {
378        Ok(field) => {
379            let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
380                Some(mime::IMAGE) => {
381                    generate_image_path(&field, StoreKind::Organization(organization.id))
382                }
383                Some(unsupported) => Err(ControllerError::new(
384                    ControllerErrorType::BadRequest,
385                    format!("Unsupported image Mime type: {}", unsupported),
386                    None,
387                )),
388                None => Err(ControllerError::new(
389                    ControllerErrorType::BadRequest,
390                    "Missing image Mime type",
391                    None,
392                )),
393            }?;
394            upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
395            Ok(path)
396        }
397        Err(err) => Err(ControllerError::new(
398            ControllerErrorType::InternalServerError,
399            err.to_string(),
400            None,
401        )),
402    }
403}
404
405// These limits must match the limits in CMS/src/services/backend/media/uploadMediaToServer.ts
406// If you modify these, update the TypeScript file as well.
407// Note: The nginx ingress also has a limit on max request size (see kubernetes/base/ingress.yml)
408const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
409    // 10 MB for images
410    (mime::IMAGE, 10 * 1024 * 1024),
411    // 100 MB for audio
412    (mime::AUDIO, 100 * 1024 * 1024),
413    // 100 MB for video
414    (mime::VIDEO, 100 * 1024 * 1024),
415    // 25 MB for documents/other files
416    (mime::APPLICATION, 25 * 1024 * 1024),
417];
418// 10 MB default fallback
419const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
420
421fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
422    mime_type
423        .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
424        .map(|(_, size)| *size)
425        .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
426}
427
428/// Uploads the data from the multipart `field` to the given `path` in file storage.
429async fn upload_field_to_storage(
430    conn: &mut PgConnection,
431    path: &Path,
432    field: mp::Field,
433    file_store: &dyn FileStore,
434    uploader: Option<AuthUser>,
435) -> Result<(), ControllerError> {
436    // Check file size limit based on mime type
437    let mime_type = field.content_type().map(|ct| ct.type_());
438    let size_limit = get_size_limit_for_mime(mime_type);
439
440    // Get size from content disposition if available
441    // Note: This does not enforce the size of the file since the client can lie about the content length
442    if let Some(content_disposition) = field.content_disposition()
443        && let Some(size_str) = content_disposition
444            .parameters
445            .iter()
446            .find_map(|p| p.as_unknown("size"))
447        && let Ok(size) = size_str.parse::<u64>()
448        && size > size_limit as u64
449    {
450        return Err(ControllerError::new(
451            ControllerErrorType::BadRequest,
452            format!(
453                "File size {} exceeds limit of {} bytes for type {}",
454                size,
455                size_limit,
456                mime_type.map_or("unknown".to_string(), |m| m.to_string())
457            ),
458            None,
459        ));
460    }
461
462    // TODO: convert archives into a uniform format
463    let mime_type = field
464        .content_type()
465        .map(|ct| ct.to_string())
466        .unwrap_or_default();
467
468    let name = {
469        let name_ref = field.name().ok_or_else(|| {
470            ControllerError::new(
471                ControllerErrorType::BadRequest,
472                "Tried to upload a file without a file name".to_string(),
473                None,
474            )
475        })?;
476        name_ref.to_string()
477    };
478
479    let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
480
481    upload_file_to_storage(
482        conn,
483        path,
484        &name,
485        &mime_type,
486        contents,
487        file_store,
488        uploader.map(|u| u.id),
489    )
490    .await?;
491    Ok(())
492}
493pub async fn upload_certificate_svg(
494    conn: &mut PgConnection,
495    file_name: &str,
496    file: GenericPayload,
497    file_store: &dyn FileStore,
498    course_id: Uuid,
499    uploader: AuthUser,
500) -> Result<(Uuid, PathBuf), ControllerError> {
501    let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
502    let safe_path = make_filename_safe(&path);
503    let id = upload_file_to_storage(
504        conn,
505        &safe_path,
506        file_name,
507        "image/svg+xml",
508        file,
509        file_store,
510        Some(uploader.id),
511    )
512    .await?;
513    Ok((id, safe_path))
514}
515
516pub struct ExerciseTaskInfo<'a> {
517    pub course_id: Uuid,
518    pub exercise: &'a Exercise,
519    pub exercise_slide: &'a ExerciseSlide,
520    pub exercise_task: &'a ExerciseTask,
521}
522
523pub async fn upload_exercise_archive(
524    conn: &mut PgConnection,
525    file: GenericPayload,
526    file_store: &dyn FileStore,
527    exercise: ExerciseTaskInfo<'_>,
528    mime: Mime,
529    uploader: Uuid,
530) -> Result<(Uuid, PathBuf), ControllerError> {
531    let file_name = &exercise.exercise.name;
532    let path = nested_path(
533        &[
534            "user-exercise-uploads",
535            "exercise",
536            &exercise.exercise.id.to_string(),
537            "slide",
538            &exercise.exercise_slide.id.to_string(),
539            "task",
540            &exercise.exercise_task.id.to_string(),
541            file_name,
542        ],
543        FileType::File,
544        StoreKind::Course(exercise.course_id),
545    );
546    let safe_path = make_filename_safe(&path);
547    let id = upload_file_to_storage(
548        conn,
549        &safe_path,
550        file_name,
551        mime.as_ref(),
552        file,
553        file_store,
554        Some(uploader),
555    )
556    .await?;
557    Ok((id, safe_path))
558}
559
560async fn upload_file_to_storage(
561    conn: &mut PgConnection,
562    path: &Path,
563    file_name: &str,
564    mime_type: &str,
565    file: GenericPayload,
566    file_store: &dyn FileStore,
567    uploader: Option<Uuid>,
568) -> Result<Uuid, ControllerError> {
569    let mut tx = conn.begin().await?;
570    let id = upload_file_to_storage_in_existing_transaction(
571        &mut tx, path, file_name, mime_type, file, file_store, uploader,
572    )
573    .await?;
574    tx.commit().await?;
575    Ok(id)
576}
577
578async fn upload_file_to_storage_in_existing_transaction(
579    conn: &mut PgConnection,
580    path: &Path,
581    file_name: &str,
582    mime_type: &str,
583    file: GenericPayload,
584    file_store: &dyn FileStore,
585    uploader: Option<Uuid>,
586) -> Result<Uuid, ControllerError> {
587    let path_string = path.to_str().context("invalid path")?.to_string();
588    let id =
589        models::file_uploads::insert(conn, file_name, &path_string, mime_type, uploader).await?;
590    file_store.upload_stream(path, file, mime_type).await?;
591    Ok(id)
592}
593
594fn make_filename_safe(path: &PathBuf) -> PathBuf {
595    let mut path_buf = path.to_owned();
596    let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
597    path_buf.set_file_name(random_string);
598    if let Some(ext) = path.extension() {
599        // For convenience, we'll keep the original extension in most cases. We'll just filter out any potentially problematic characters.
600        let ext = ext
601            .to_str()
602            .unwrap_or("")
603            .chars()
604            .filter(|c| c.is_alphanumeric())
605            .collect::<String>();
606        path_buf.set_extension(ext);
607    }
608    path_buf
609}
610
611pub async fn delete_file_from_storage(
612    conn: &mut PgConnection,
613    id: Uuid,
614    file_store: &dyn FileStore,
615) -> Result<(), ControllerError> {
616    let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
617    file_store.delete(Path::new(&file_to_delete)).await?;
618    Ok(())
619}
620
621/// Generates a path for an audio file with the appropriate extension.
622fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
623    let extension = match field
624        .content_type()
625        .map(|ct| ct.to_string())
626        .unwrap_or_default()
627        .as_str()
628    {
629        "audio/aac" => ".aac",
630        "audio/mpeg" => ".mp3",
631        "audio/ogg" => ".oga",
632        "audio/opus" => ".opus",
633        "audio/wav" => ".wav",
634        "audio/webm" => ".weba",
635        "audio/midi" => ".mid",
636        "audio/x-midi" => ".mid",
637        unsupported => {
638            return Err(ControllerError::new(
639                ControllerErrorType::BadRequest,
640                format!("Unsupported audio Mime type: {}", unsupported),
641                None,
642            ));
643        }
644    };
645    let mut file_name = generate_random_string(30);
646    file_name.push_str(extension);
647    let path = path(&file_name, FileType::Audio, store_kind);
648    Ok(path)
649}
650
651/// Generates a path for a generic file with the appropriate extension based on its filename.
652fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
653    let field_content = field.content_disposition().ok_or_else(|| {
654        ControllerError::new(
655            ControllerErrorType::BadRequest,
656            "No content disposition in uploaded file".to_string(),
657            None,
658        )
659    })?;
660    let field_content_name = field_content.get_filename().ok_or_else(|| {
661        ControllerError::new(
662            ControllerErrorType::BadRequest,
663            "Missing file name in content-disposition",
664            None,
665        )
666    })?;
667
668    let mut file_name = generate_random_string(30);
669    let uploaded_file_extension = get_extension_from_filename(field_content_name);
670    if let Some(extension) = uploaded_file_extension {
671        file_name.push_str(format!(".{}", extension).as_str());
672    }
673
674    let path = path(&file_name, FileType::File, store_kind);
675    Ok(path)
676}
677
678/// Generates a path for an image file with the appropriate extension.
679fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
680    let extension = match field
681        .content_type()
682        .map(|ct| ct.to_string())
683        .unwrap_or_default()
684        .as_str()
685    {
686        "image/jpeg" => ".jpg",
687        "image/png" => ".png",
688        "image/svg+xml" => ".svg",
689        "image/tiff" => ".tif",
690        "image/bmp" => ".bmp",
691        "image/webp" => ".webp",
692        "image/gif" => ".gif",
693        unsupported => {
694            return Err(ControllerError::new(
695                ControllerErrorType::BadRequest,
696                format!("Unsupported image Mime type: {}", unsupported),
697                None,
698            ));
699        }
700    };
701
702    // using a random string for the image name because
703    // a) we don't want the filename to be user controllable
704    // b) we don't want the filename to be too easily guessable (so no uuid)
705    let mut file_name = generate_random_string(30);
706    file_name.push_str(extension);
707    let path = path(&file_name, FileType::Image, store_kind);
708    Ok(path)
709}
710
711/// Generates a path for an audio file with the appropriate extension.
712async fn validate_media_headers(
713    headers: &HeaderMap,
714    user: &AuthUser,
715    conn: &mut PgConnection,
716) -> ControllerResult<()> {
717    let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
718        ControllerError::new(
719            ControllerErrorType::BadRequest,
720            "Please provide a Content-Type header",
721            None,
722        )
723    })?;
724    let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
725
726    if !content_type_string.contains("multipart/form-data") {
727        return Err(ControllerError::new(
728            ControllerErrorType::BadRequest,
729            format!("Unsupported type: {}", content_type_string),
730            None,
731        ));
732    }
733
734    let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
735        ControllerError::new(
736            ControllerErrorType::BadRequest,
737            "Please provide a Content-Length in header",
738            None,
739        )
740    })?;
741    let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
742        .to_string()
743        .parse::<i32>()
744        .map_err(|original_err| {
745            ControllerError::new(
746                ControllerErrorType::InternalServerError,
747                original_err.to_string(),
748                Some(original_err.into()),
749            )
750        })?;
751
752    let mime_type = headers
753        .get("X-File-Type")
754        .map(|h| h.to_str().unwrap_or("application/octet-stream"))
755        .unwrap_or("application/octet-stream")
756        .split('/')
757        .next()
758        .map(|s| match s {
759            "image" => mime::IMAGE,
760            "audio" => mime::AUDIO,
761            "video" => mime::VIDEO,
762            "application" => mime::APPLICATION,
763            _ => mime::APPLICATION,
764        });
765    let size_limit = get_size_limit_for_mime(mime_type);
766
767    // Note: This does not enforce the size of the file since the client can lie about the content length
768    if content_length_number > size_limit {
769        return Err(ControllerError::new(
770            ControllerErrorType::BadRequest,
771            format!(
772                "File size {} exceeds limit of {} bytes for type {}",
773                content_length_number,
774                size_limit,
775                mime_type.map_or("unknown".to_string(), |m| m.to_string())
776            ),
777            None,
778        ));
779    }
780
781    let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
782    token.authorized_ok(())
783}
784
785enum FileType {
786    Image,
787    Audio,
788    File,
789}
790
791fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
792    nested_path(&[file_name], file_type, store_kind)
793}
794
795fn nested_path(components: &[&str], file_type: FileType, store_kind: StoreKind) -> PathBuf {
796    let (base_dir, base_id) = match store_kind {
797        StoreKind::Organization(id) => ("organization", id),
798        StoreKind::Course(id) => ("course", id),
799        StoreKind::Exam(id) => ("exam", id),
800    };
801    let file_type_subdir = match file_type {
802        FileType::Image => "images",
803        FileType::Audio => "audios",
804        FileType::File => "files",
805    };
806    [base_dir, &base_id.to_string(), file_type_subdir]
807        .iter()
808        .chain(components)
809        .collect()
810}