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