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