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::HashMap, path::Path};
22use std::{path::PathBuf, sync::Arc};
23
24/// Processes an upload from an exercise service or an exercise iframe.
25/// This function assumes that any permission checks have already been made.
26pub async fn process_exercise_service_upload(
27    conn: &mut PgConnection,
28    exercise_service_slug: &str,
29    mut payload: Multipart,
30    file_store: &dyn FileStore,
31    paths: &mut HashMap<String, String>,
32    uploader: Option<AuthUser>,
33    base_url: &str,
34) -> Result<(), ControllerError> {
35    let mut tx = conn.begin().await?;
36    while let Some(item) = payload.next().await {
37        let field = item.unwrap();
38        let field_name = {
39            let name_ref = field.name().ok_or_else(|| {
40                ControllerError::new(
41                    ControllerErrorType::BadRequest,
42                    "Tried to upload a file without a file name".to_string(),
43                    None,
44                )
45            })?;
46            name_ref.to_string()
47        };
48
49        let random_filename = generate_random_string(32);
50        let path = format!("{exercise_service_slug}/{random_filename}");
51
52        upload_field_to_storage(&mut tx, Path::new(&path), field, file_store, uploader).await?;
53        let url = format!("{base_url}/api/v0/files/{path}");
54        paths.insert(field_name, url);
55    }
56    tx.commit().await?;
57    Ok(())
58}
59
60#[derive(Debug, Clone, Copy, Deserialize)]
61#[cfg_attr(feature = "ts_rs", derive(TS))]
62pub enum StoreKind {
63    Organization(Uuid),
64    Course(Uuid),
65    Exam(Uuid),
66}
67
68/// Processes an upload from CMS.
69pub async fn upload_file_from_cms(
70    headers: &HeaderMap,
71    mut payload: Multipart,
72    store_kind: StoreKind,
73    file_store: &dyn FileStore,
74    conn: &mut PgConnection,
75    user: AuthUser,
76) -> Result<PathBuf, ControllerError> {
77    let file_payload = payload.next().await.ok_or_else(|| {
78        ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
79    })?;
80    match file_payload {
81        Ok(field) => {
82            upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
83        }
84        Err(err) => Err(ControllerError::new(
85            ControllerErrorType::InternalServerError,
86            err.to_string(),
87            None,
88        )),
89    }
90}
91
92/// Processes an upload from CMS.
93pub async fn upload_field_from_cms(
94    headers: &HeaderMap,
95    field: Field,
96    store_kind: StoreKind,
97    file_store: &dyn FileStore,
98    conn: &mut PgConnection,
99    user: AuthUser,
100) -> Result<PathBuf, ControllerError> {
101    validate_media_headers(headers, &user, conn).await?;
102    let path = match field.content_type().map(|ct| ct.type_()) {
103        Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
104        Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
105        _ => generate_file_path(&field, store_kind)?,
106    };
107    upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
108    Ok(path)
109}
110
111/// Processes an upload for an organization's image.
112pub async fn upload_image_for_organization(
113    headers: &HeaderMap,
114    mut payload: Multipart,
115    organization: &DatabaseOrganization,
116    file_store: &Arc<dyn FileStore>,
117    user: AuthUser,
118    conn: &mut PgConnection,
119) -> Result<PathBuf, ControllerError> {
120    validate_media_headers(headers, &user, conn).await?;
121    let next_payload: Result<Field, mp::MultipartError> =
122        payload.next().await.ok_or_else(|| {
123            ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
124        })?;
125    match next_payload {
126        Ok(field) => {
127            let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
128                Some(mime::IMAGE) => {
129                    generate_image_path(&field, StoreKind::Organization(organization.id))
130                }
131                Some(unsupported) => Err(ControllerError::new(
132                    ControllerErrorType::BadRequest,
133                    format!("Unsupported image Mime type: {}", unsupported),
134                    None,
135                )),
136                None => Err(ControllerError::new(
137                    ControllerErrorType::BadRequest,
138                    "Missing image Mime type",
139                    None,
140                )),
141            }?;
142            upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
143            Ok(path)
144        }
145        Err(err) => Err(ControllerError::new(
146            ControllerErrorType::InternalServerError,
147            err.to_string(),
148            None,
149        )),
150    }
151}
152
153// These limits must match the limits in CMS/src/services/backend/media/uploadMediaToServer.ts
154// If you modify these, update the TypeScript file as well.
155// Note: The nginx ingress also has a limit on max request size (see kubernetes/base/ingress.yml)
156const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
157    // 10 MB for images
158    (mime::IMAGE, 10 * 1024 * 1024),
159    // 100 MB for audio
160    (mime::AUDIO, 100 * 1024 * 1024),
161    // 100 MB for video
162    (mime::VIDEO, 100 * 1024 * 1024),
163    // 25 MB for documents/other files
164    (mime::APPLICATION, 25 * 1024 * 1024),
165];
166// 10 MB default fallback
167const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
168
169fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
170    mime_type
171        .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
172        .map(|(_, size)| *size)
173        .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
174}
175
176/// Uploads the data from the multipart `field` to the given `path` in file storage.
177async fn upload_field_to_storage(
178    conn: &mut PgConnection,
179    path: &Path,
180    field: mp::Field,
181    file_store: &dyn FileStore,
182    uploader: Option<AuthUser>,
183) -> Result<(), ControllerError> {
184    // Check file size limit based on mime type
185    let mime_type = field.content_type().map(|ct| ct.type_());
186    let size_limit = get_size_limit_for_mime(mime_type);
187
188    // Get size from content disposition if available
189    // Note: This does not enforce the size of the file since the client can lie about the content length
190    if let Some(content_disposition) = field.content_disposition() {
191        if let Some(size_str) = content_disposition
192            .parameters
193            .iter()
194            .find_map(|p| p.as_unknown("size"))
195        {
196            if let Ok(size) = size_str.parse::<u64>() {
197                if size > size_limit as u64 {
198                    return Err(ControllerError::new(
199                        ControllerErrorType::BadRequest,
200                        format!(
201                            "File size {} exceeds limit of {} bytes for type {}",
202                            size,
203                            size_limit,
204                            mime_type.map_or("unknown".to_string(), |m| m.to_string())
205                        ),
206                        None,
207                    ));
208                }
209            }
210        }
211    }
212
213    // TODO: convert archives into a uniform format
214    let mime_type = field
215        .content_type()
216        .map(|ct| ct.to_string())
217        .unwrap_or_default();
218
219    let name = {
220        let name_ref = field.name().ok_or_else(|| {
221            ControllerError::new(
222                ControllerErrorType::BadRequest,
223                "Tried to upload a file without a file name".to_string(),
224                None,
225            )
226        })?;
227        name_ref.to_string()
228    };
229
230    let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
231
232    upload_file_to_storage(
233        conn,
234        path,
235        &name,
236        &mime_type,
237        contents,
238        file_store,
239        uploader.map(|u| u.id),
240    )
241    .await?;
242    Ok(())
243}
244pub async fn upload_certificate_svg(
245    conn: &mut PgConnection,
246    file_name: &str,
247    file: GenericPayload,
248    file_store: &dyn FileStore,
249    course_id: Uuid,
250    uploader: AuthUser,
251) -> Result<(Uuid, PathBuf), ControllerError> {
252    let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
253    let safe_path = make_filename_safe(&path);
254    let id = upload_file_to_storage(
255        conn,
256        &safe_path,
257        file_name,
258        "image/svg+xml",
259        file,
260        file_store,
261        Some(uploader.id),
262    )
263    .await?;
264    Ok((id, safe_path))
265}
266
267pub struct ExerciseTaskInfo<'a> {
268    pub course_id: Uuid,
269    pub exercise: &'a Exercise,
270    pub exercise_slide: &'a ExerciseSlide,
271    pub exercise_task: &'a ExerciseTask,
272}
273
274pub async fn upload_exercise_archive(
275    conn: &mut PgConnection,
276    file: GenericPayload,
277    file_store: &dyn FileStore,
278    exercise: ExerciseTaskInfo<'_>,
279    mime: Mime,
280    uploader: Uuid,
281) -> Result<(Uuid, PathBuf), ControllerError> {
282    let file_name = &exercise.exercise.name;
283    let path = nested_path(
284        &[
285            "user-exercise-uploads",
286            "exercise",
287            &exercise.exercise.id.to_string(),
288            "slide",
289            &exercise.exercise_slide.id.to_string(),
290            "task",
291            &exercise.exercise_task.id.to_string(),
292            file_name,
293        ],
294        FileType::File,
295        StoreKind::Course(exercise.course_id),
296    );
297    let safe_path = make_filename_safe(&path);
298    let id = upload_file_to_storage(
299        conn,
300        &safe_path,
301        file_name,
302        mime.as_ref(),
303        file,
304        file_store,
305        Some(uploader),
306    )
307    .await?;
308    Ok((id, safe_path))
309}
310
311async fn upload_file_to_storage(
312    conn: &mut PgConnection,
313    path: &Path,
314    file_name: &str,
315    mime_type: &str,
316    file: GenericPayload,
317    file_store: &dyn FileStore,
318    uploader: Option<Uuid>,
319) -> Result<Uuid, ControllerError> {
320    let mut tx = conn.begin().await?;
321    let path_string = path.to_str().context("invalid path")?.to_string();
322    let id =
323        models::file_uploads::insert(&mut tx, file_name, &path_string, mime_type, uploader).await?;
324    file_store.upload_stream(path, file, mime_type).await?;
325    tx.commit().await?;
326    Ok(id)
327}
328
329fn make_filename_safe(path: &PathBuf) -> PathBuf {
330    let mut path_buf = path.to_owned();
331    let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
332    path_buf.set_file_name(random_string);
333    if let Some(ext) = path.extension() {
334        // For convenience, we'll keep the original extension in most cases. We'll just filter out any potentially problematic characters.
335        let ext = ext
336            .to_str()
337            .unwrap_or("")
338            .chars()
339            .filter(|c| c.is_alphanumeric())
340            .collect::<String>();
341        path_buf.set_extension(ext);
342    }
343    path_buf
344}
345
346pub async fn delete_file_from_storage(
347    conn: &mut PgConnection,
348    id: Uuid,
349    file_store: &dyn FileStore,
350) -> Result<(), ControllerError> {
351    let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
352    file_store.delete(Path::new(&file_to_delete)).await?;
353    Ok(())
354}
355
356/// Generates a path for an audio file with the appropriate extension.
357fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
358    let extension = match field
359        .content_type()
360        .map(|ct| ct.to_string())
361        .unwrap_or_default()
362        .as_str()
363    {
364        "audio/aac" => ".aac",
365        "audio/mpeg" => ".mp3",
366        "audio/ogg" => ".oga",
367        "audio/opus" => ".opus",
368        "audio/wav" => ".wav",
369        "audio/webm" => ".weba",
370        "audio/midi" => ".mid",
371        "audio/x-midi" => ".mid",
372        unsupported => {
373            return Err(ControllerError::new(
374                ControllerErrorType::BadRequest,
375                format!("Unsupported audio Mime type: {}", unsupported),
376                None,
377            ));
378        }
379    };
380    let mut file_name = generate_random_string(30);
381    file_name.push_str(extension);
382    let path = path(&file_name, FileType::Audio, store_kind);
383    Ok(path)
384}
385
386/// Generates a path for a generic file with the appropriate extension based on its filename.
387fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
388    let field_content = field.content_disposition().ok_or_else(|| {
389        ControllerError::new(
390            ControllerErrorType::BadRequest,
391            "No content disposition in uploaded file".to_string(),
392            None,
393        )
394    })?;
395    let field_content_name = field_content.get_filename().ok_or_else(|| {
396        ControllerError::new(
397            ControllerErrorType::BadRequest,
398            "Missing file name in content-disposition",
399            None,
400        )
401    })?;
402
403    let mut file_name = generate_random_string(30);
404    let uploaded_file_extension = get_extension_from_filename(field_content_name);
405    if let Some(extension) = uploaded_file_extension {
406        file_name.push_str(format!(".{}", extension).as_str());
407    }
408
409    let path = path(&file_name, FileType::File, store_kind);
410    Ok(path)
411}
412
413/// Generates a path for an image file with the appropriate extension.
414fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
415    let extension = match field
416        .content_type()
417        .map(|ct| ct.to_string())
418        .unwrap_or_default()
419        .as_str()
420    {
421        "image/jpeg" => ".jpg",
422        "image/png" => ".png",
423        "image/svg+xml" => ".svg",
424        "image/tiff" => ".tif",
425        "image/bmp" => ".bmp",
426        "image/webp" => ".webp",
427        "image/gif" => ".gif",
428        unsupported => {
429            return Err(ControllerError::new(
430                ControllerErrorType::BadRequest,
431                format!("Unsupported image Mime type: {}", unsupported),
432                None,
433            ));
434        }
435    };
436
437    // using a random string for the image name because
438    // a) we don't want the filename to be user controllable
439    // b) we don't want the filename to be too easily guessable (so no uuid)
440    let mut file_name = generate_random_string(30);
441    file_name.push_str(extension);
442    let path = path(&file_name, FileType::Image, store_kind);
443    Ok(path)
444}
445
446/// Generates a path for an audio file with the appropriate extension.
447async fn validate_media_headers(
448    headers: &HeaderMap,
449    user: &AuthUser,
450    conn: &mut PgConnection,
451) -> ControllerResult<()> {
452    let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
453        ControllerError::new(
454            ControllerErrorType::BadRequest,
455            "Please provide a Content-Type header",
456            None,
457        )
458    })?;
459    let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
460
461    if !content_type_string.contains("multipart/form-data") {
462        return Err(ControllerError::new(
463            ControllerErrorType::BadRequest,
464            format!("Unsupported type: {}", content_type_string),
465            None,
466        ));
467    }
468
469    let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
470        ControllerError::new(
471            ControllerErrorType::BadRequest,
472            "Please provide a Content-Length in header",
473            None,
474        )
475    })?;
476    let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
477        .to_string()
478        .parse::<i32>()
479        .map_err(|original_err| {
480            ControllerError::new(
481                ControllerErrorType::InternalServerError,
482                original_err.to_string(),
483                Some(original_err.into()),
484            )
485        })?;
486
487    let mime_type = headers
488        .get("X-File-Type")
489        .map(|h| h.to_str().unwrap_or("application/octet-stream"))
490        .unwrap_or("application/octet-stream")
491        .split('/')
492        .next()
493        .map(|s| match s {
494            "image" => mime::IMAGE,
495            "audio" => mime::AUDIO,
496            "video" => mime::VIDEO,
497            "application" => mime::APPLICATION,
498            _ => mime::APPLICATION,
499        });
500    let size_limit = get_size_limit_for_mime(mime_type);
501
502    // Note: This does not enforce the size of the file since the client can lie about the content length
503    if content_length_number > size_limit {
504        return Err(ControllerError::new(
505            ControllerErrorType::BadRequest,
506            format!(
507                "File size {} exceeds limit of {} bytes for type {}",
508                content_length_number,
509                size_limit,
510                mime_type.map_or("unknown".to_string(), |m| m.to_string())
511            ),
512            None,
513        ));
514    }
515
516    let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
517    token.authorized_ok(())
518}
519
520enum FileType {
521    Image,
522    Audio,
523    File,
524}
525
526fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
527    nested_path(&[file_name], file_type, store_kind)
528}
529
530fn nested_path(components: &[&str], file_type: FileType, store_kind: StoreKind) -> PathBuf {
531    let (base_dir, base_id) = match store_kind {
532        StoreKind::Organization(id) => ("organization", id),
533        StoreKind::Course(id) => ("course", id),
534        StoreKind::Exam(id) => ("exam", id),
535    };
536    let file_type_subdir = match file_type {
537        FileType::Image => "images",
538        FileType::Audio => "audios",
539        FileType::File => "files",
540    };
541    [base_dir, &base_id.to_string(), file_type_subdir]
542        .iter()
543        .chain(components)
544        .collect()
545}