1pub use crate::domain::authorization::AuthorizationToken;
4use crate::prelude::*;
5use actix_http::header::HeaderMap;
6use actix_multipart as mp;
7use actix_multipart::Field;
8use actix_web::http::header;
9use futures::{StreamExt, TryStreamExt};
10use headless_lms_utils::file_store::{FileStore, GenericPayload};
11use headless_lms_utils::{
12 file_store::file_utils::get_extension_from_filename, strings::generate_random_string,
13};
14use models::organizations::DatabaseOrganization;
15use rand::distr::Alphanumeric;
16use rand::distr::SampleString;
17use std::{collections::HashSet, path::Path};
18use std::{
19 path::PathBuf,
20 sync::{
21 Arc, Mutex,
22 atomic::{AtomicU64, Ordering},
23 },
24};
25use utoipa::ToSchema;
26
27const EXERCISE_UPLOAD_MAX_FILES: usize = 10;
28const EXERCISE_UPLOAD_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
29const EXERCISE_UPLOAD_MAX_BATCH_BYTES: u64 = 100 * 1024 * 1024;
30
31#[derive(Debug, Clone, Serialize, ToSchema)]
34pub struct ExerciseServiceUploadResultEntry {
35 pub id: Uuid,
36 pub url: String,
37}
38
39pub struct ExerciseServiceUpload {
42 pub entry: ExerciseServiceUploadResultEntry,
43 pub name: String,
44 pub mime: String,
45 pub size_bytes: i64,
46}
47
48pub struct ExerciseServiceUploadCleanup {
50 pub path: String,
51}
52
53pub struct UploadCleanup {
62 pub uploaded_paths: Vec<ExerciseServiceUploadCleanup>,
63 file_store: web::Data<dyn FileStore>,
64 armed: bool,
65}
66
67impl UploadCleanup {
68 pub fn new(file_store: web::Data<dyn FileStore>) -> Self {
69 Self {
70 uploaded_paths: Vec::new(),
71 file_store,
72 armed: true,
73 }
74 }
75
76 pub async fn clean_up(&mut self) {
79 self.armed = false;
80 for uploaded in std::mem::take(&mut self.uploaded_paths) {
81 if let Err(delete_error) = self.file_store.delete(Path::new(&uploaded.path)).await {
82 error!(
83 "Failed to delete file '{}' during cleanup: {delete_error}",
84 uploaded.path
85 );
86 }
87 }
88 }
89
90 pub fn disarm(&mut self) {
93 self.armed = false;
94 }
95}
96
97impl Drop for UploadCleanup {
98 fn drop(&mut self) {
99 if !self.armed || self.uploaded_paths.is_empty() {
100 return;
101 }
102 let uploaded_paths = std::mem::take(&mut self.uploaded_paths);
103 let file_store = self.file_store.clone();
104 actix_web::rt::spawn(async move {
106 for uploaded in uploaded_paths {
107 if let Err(delete_error) = file_store.delete(Path::new(&uploaded.path)).await {
108 error!(
109 "Failed to delete file '{}' during cleanup: {delete_error}",
110 uploaded.path
111 );
112 }
113 }
114 });
115 }
116}
117
118struct ExerciseServiceUploadMetadata {
119 file_upload_id: Uuid,
120 path: String,
121 filename: String,
122 mime_type: String,
123 size_bytes: i64,
124 url: String,
125}
126
127pub struct AnswerUploadDestination {
129 pub owner: CourseOrExamId,
131 pub exercise_id: Uuid,
132 pub user_id: Uuid,
133}
134
135pub enum UploadPathScheme<'a> {
137 ExerciseService { exercise_service_slug: &'a str },
141 Answer(&'a AnswerUploadDestination),
147}
148
149impl UploadPathScheme<'_> {
150 fn path(&self, file_upload_id: Uuid) -> String {
153 match self {
154 Self::ExerciseService {
155 exercise_service_slug,
156 } => format!("{exercise_service_slug}/{}", generate_random_string(32)),
157 Self::Answer(AnswerUploadDestination {
158 owner,
159 exercise_id,
160 user_id,
161 }) => {
162 let (owner_kind, owner_id) = match owner {
165 CourseOrExamId::Course(course_id) => ("course", course_id),
166 CourseOrExamId::Exam(exam_id) => ("exam", exam_id),
167 };
168 format!(
169 "answers/v1/{owner_kind}/{owner_id}/exercise/{exercise_id}/user/{user_id}/{file_upload_id}"
170 )
171 }
172 }
173 }
174
175 fn download_url(
177 &self,
178 file_upload_id: Uuid,
179 path: &str,
180 file_store: &dyn FileStore,
181 app_conf: &ApplicationConfiguration,
182 ) -> Result<String, ControllerError> {
183 match self {
184 Self::ExerciseService { .. } => {
185 Ok(format!("{}/api/v0/files/{path}", app_conf.base_url))
186 }
187 Self::Answer(_) => Ok(file_store.get_claimed_download_url(file_upload_id, app_conf)?),
188 }
189 }
190}
191
192pub async fn process_exercise_service_upload(
199 conn: &mut PgConnection,
200 exercise_service_slug: &str,
201 payload: Multipart,
202 file_store: &dyn FileStore,
203 uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
204 uploader: Option<Uuid>,
205 app_conf: &ApplicationConfiguration,
206) -> Result<Vec<ExerciseServiceUpload>, ControllerError> {
207 let streamed = stream_exercise_service_upload(
208 UploadPathScheme::ExerciseService {
209 exercise_service_slug,
210 },
211 payload,
212 file_store,
213 uploaded_paths,
214 app_conf,
215 )
216 .await?;
217 let mut tx = conn.begin().await?;
218 let uploads = record_exercise_service_upload(&mut tx, streamed, uploader).await?;
219 let file_upload_ids: Vec<Uuid> = uploads.iter().map(|upload| upload.entry.id).collect();
220 models::exercise_spec_uploads::insert_many(
223 &mut tx,
224 exercise_service_slug,
225 uploader,
226 &file_upload_ids,
227 )
228 .await?;
229 tx.commit().await?;
230 Ok(uploads)
231}
232
233pub struct StreamedExerciseServiceUpload {
235 parts: Vec<ExerciseServiceUploadMetadata>,
236}
237
238pub async fn stream_exercise_service_upload(
245 path_scheme: UploadPathScheme<'_>,
246 mut payload: Multipart,
247 file_store: &dyn FileStore,
248 uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
249 app_conf: &ApplicationConfiguration,
250) -> Result<StreamedExerciseServiceUpload, ControllerError> {
251 let mut parts = Vec::new();
252 let mut ids = HashSet::new();
253 let batch_bytes = Arc::new(AtomicU64::new(0));
254 while let Some(item) = payload.next().await {
255 let field = item.map_err(|err| {
256 controller_err!(
257 BadRequest,
258 format!("Failed to read multipart field: {}", err),
259 anyhow::anyhow!("Multipart error: {}", err)
260 )
261 })?;
262 validate_exercise_upload_file_count(parts.len())?;
263 let field_name = {
264 let name_ref = field.name().ok_or_else(|| {
265 controller_err!(
266 BadRequest,
267 "Tried to upload a multipart field without a field name or field ID"
268 .to_string()
269 )
270 })?;
271 name_ref.to_string()
272 };
273
274 validate_exercise_upload_id(&field_name, &mut ids)?;
275 let filename = validate_exercise_upload_filename(
276 field
277 .content_disposition()
278 .and_then(|disposition| disposition.get_filename()),
279 )?
280 .to_string();
281
282 let file_upload_id = Uuid::new_v4();
285 let path = path_scheme.path(file_upload_id);
286 uploaded_paths.push(ExerciseServiceUploadCleanup { path: path.clone() });
287 let mime_type = field
288 .content_type()
289 .map(ToString::to_string)
290 .unwrap_or_default();
291 let (stream, stream_error, uploaded_bytes) =
292 limited_exercise_upload_stream(field, batch_bytes.clone());
293 let upload_result = file_store
294 .upload_stream(Path::new(&path), stream, &mime_type)
295 .await;
296 if let Some(error) = stream_error
297 .lock()
298 .unwrap_or_else(|poisoned| poisoned.into_inner())
299 .take()
300 {
301 return Err(error);
302 }
303 upload_result?;
304 let url = path_scheme.download_url(file_upload_id, &path, file_store, app_conf)?;
305 parts.push(ExerciseServiceUploadMetadata {
306 file_upload_id,
307 path,
308 filename,
309 mime_type,
310 size_bytes: uploaded_bytes.load(Ordering::SeqCst) as i64,
311 url,
312 });
313 }
314 validate_exercise_upload_not_empty(parts.len())?;
315 Ok(StreamedExerciseServiceUpload { parts })
316}
317
318pub async fn record_exercise_service_upload(
323 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
324 streamed: StreamedExerciseServiceUpload,
325 uploader: Option<Uuid>,
326) -> Result<Vec<ExerciseServiceUpload>, ControllerError> {
327 let StreamedExerciseServiceUpload { parts } = streamed;
328 let mut uploads = Vec::with_capacity(parts.len());
329 for part in parts {
330 models::file_uploads::insert_with_id(
331 tx,
332 part.file_upload_id,
333 &part.filename,
334 &part.path,
335 &part.mime_type,
336 uploader,
337 Some(part.size_bytes),
338 )
339 .await?;
340 uploads.push(ExerciseServiceUpload {
341 entry: ExerciseServiceUploadResultEntry {
342 id: part.file_upload_id,
343 url: part.url,
344 },
345 name: part.filename,
346 mime: part.mime_type,
347 size_bytes: part.size_bytes,
348 });
349 }
350 Ok(uploads)
351}
352
353fn limited_exercise_upload_stream(
357 field: mp::Field,
358 batch_bytes: Arc<AtomicU64>,
359) -> (
360 GenericPayload,
361 Arc<Mutex<Option<ControllerError>>>,
362 Arc<AtomicU64>,
363) {
364 let per_file_bytes = Arc::new(AtomicU64::new(0));
365 let stream_error = Arc::new(Mutex::new(None));
366 let payload_stream_error = stream_error.clone();
367 let payload_per_file_bytes = per_file_bytes.clone();
368 let stream = Box::pin(futures::stream::try_unfold(
369 (
370 field,
371 payload_per_file_bytes,
372 batch_bytes,
373 payload_stream_error,
374 ),
375 |(mut field, per_file_bytes, batch_bytes, stream_error)| async move {
376 let Some(chunk) = field.next().await else {
377 return Ok(None);
378 };
379 let chunk = chunk.map_err(|error| anyhow::Error::msg(error.to_string()))?;
380 if let Err(error) =
381 consume_exercise_upload_bytes(&per_file_bytes, &batch_bytes, chunk.len())
382 {
383 *stream_error
384 .lock()
385 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(error);
386 return Err(anyhow::Error::msg("Exercise upload exceeds the size limit"));
387 }
388 Ok(Some((
389 chunk,
390 (field, per_file_bytes, batch_bytes, stream_error),
391 )))
392 },
393 ));
394 (stream, stream_error, per_file_bytes)
395}
396
397fn validate_exercise_upload_file_count(files_received: usize) -> Result<(), ControllerError> {
398 if files_received < EXERCISE_UPLOAD_MAX_FILES {
399 return Ok(());
400 }
401 Err(controller_err!(
402 BadRequest,
403 format!("A maximum of {EXERCISE_UPLOAD_MAX_FILES} files can be uploaded at once")
404 ))
405}
406
407fn validate_exercise_upload_id(
408 field_name: &str,
409 ids: &mut HashSet<String>,
410) -> Result<(), ControllerError> {
411 Uuid::parse_str(field_name).map_err(|_| {
412 controller_err!(
413 BadRequest,
414 "Each exercise upload field name must be a UUID".to_string()
415 )
416 })?;
417 if ids.insert(field_name.to_string()) {
418 return Ok(());
419 }
420 Err(controller_err!(
421 BadRequest,
422 "Duplicate exercise upload field id".to_string()
423 ))
424}
425
426fn validate_exercise_upload_filename(filename: Option<&str>) -> Result<&str, ControllerError> {
427 filename
428 .filter(|filename| !filename.is_empty())
429 .ok_or_else(|| {
430 controller_err!(
431 BadRequest,
432 "Every exercise upload part must be a file with a filename".to_string()
433 )
434 })
435}
436
437fn validate_exercise_upload_not_empty(files_received: usize) -> Result<(), ControllerError> {
438 if files_received > 0 {
439 return Ok(());
440 }
441 Err(controller_err!(
442 BadRequest,
443 "At least one file must be uploaded".to_string()
444 ))
445}
446
447fn consume_exercise_upload_bytes(
448 per_file_bytes: &AtomicU64,
449 batch_bytes: &AtomicU64,
450 chunk_len: usize,
451) -> Result<(), ControllerError> {
452 let chunk_len = u64::try_from(chunk_len).map_err(|error| {
453 controller_err!(
454 BadRequest,
455 "exercise upload chunk length overflow".to_string(),
456 error
457 )
458 })?;
459 let file_total = per_file_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
460 let batch_total = batch_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
461 if file_total <= EXERCISE_UPLOAD_MAX_FILE_BYTES
462 && batch_total <= EXERCISE_UPLOAD_MAX_BATCH_BYTES
463 {
464 return Ok(());
465 }
466 Err(controller_err!(
467 BadRequest,
468 "Exercise upload exceeds the 100 MiB per-file or batch limit"
469 ))
470}
471
472#[cfg(test)]
473mod exercise_upload_tests {
474 use super::*;
475
476 #[test]
477 fn exercise_upload_ids_must_be_unique_uuids() {
478 let id = Uuid::new_v4().to_string();
479 let mut ids = HashSet::new();
480
481 assert!(validate_exercise_upload_id(&id, &mut ids).is_ok());
482 assert!(
483 validate_exercise_upload_id(&id, &mut ids)
484 .unwrap_err()
485 .to_string()
486 .contains("Duplicate")
487 );
488 assert!(
489 validate_exercise_upload_id("filename.pdf", &mut ids)
490 .unwrap_err()
491 .to_string()
492 .contains("must be a UUID")
493 );
494 }
495
496 #[test]
497 fn exercise_upload_limits_are_enforced_from_streamed_bytes() {
498 let per_file = AtomicU64::new(EXERCISE_UPLOAD_MAX_FILE_BYTES - 1);
499 let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES - 1);
500 assert!(consume_exercise_upload_bytes(&per_file, &batch, 1).is_ok());
501 let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
502 assert!(matches!(
503 error.error_type(),
504 ControllerErrorType::BadRequest
505 ));
506
507 let per_file = AtomicU64::new(0);
508 let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES);
509 let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
510 assert!(matches!(
511 error.error_type(),
512 ControllerErrorType::BadRequest
513 ));
514 }
515
516 #[test]
517 fn exercise_upload_rejects_an_eleventh_file() {
518 assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES - 1).is_ok());
519 assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES).is_err());
520 }
521
522 #[test]
523 fn exercise_upload_rejects_empty_and_non_file_parts() {
524 assert!(validate_exercise_upload_not_empty(0).is_err());
525 assert!(validate_exercise_upload_not_empty(1).is_ok());
526 assert!(validate_exercise_upload_filename(None).is_err());
527 assert!(validate_exercise_upload_filename(Some("")).is_err());
528 assert_eq!(
529 validate_exercise_upload_filename(Some("report.pdf")).unwrap(),
530 "report.pdf"
531 );
532 }
533}
534
535#[derive(Debug, Clone, Copy, Deserialize)]
536
537pub enum StoreKind {
538 Organization(Uuid),
539 Course(Uuid),
540 Exam(Uuid),
541}
542
543pub async fn upload_file_from_cms(
545 headers: &HeaderMap,
546 mut payload: Multipart,
547 store_kind: StoreKind,
548 file_store: &dyn FileStore,
549 conn: &mut PgConnection,
550 user: AuthUser,
551) -> Result<PathBuf, ControllerError> {
552 let file_payload = payload.next().await.ok_or_else(|| {
553 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
554 })?;
555 match file_payload {
556 Ok(field) => {
557 upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
558 }
559 Err(err) => Err(ControllerError::new(
560 ControllerErrorType::InternalServerError,
561 err.to_string(),
562 None,
563 )),
564 }
565}
566
567pub async fn upload_field_from_cms(
569 headers: &HeaderMap,
570 field: Field,
571 store_kind: StoreKind,
572 file_store: &dyn FileStore,
573 conn: &mut PgConnection,
574 user: AuthUser,
575) -> Result<PathBuf, ControllerError> {
576 validate_media_headers(headers, &user, conn).await?;
577 let path = match field.content_type().map(|ct| ct.type_()) {
578 Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
579 Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
580 _ => generate_file_path(&field, store_kind)?,
581 };
582 upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
583 Ok(path)
584}
585
586pub async fn upload_image_for_organization(
588 headers: &HeaderMap,
589 mut payload: Multipart,
590 organization: &DatabaseOrganization,
591 file_store: &Arc<dyn FileStore>,
592 user: AuthUser,
593 conn: &mut PgConnection,
594) -> Result<PathBuf, ControllerError> {
595 validate_media_headers(headers, &user, conn).await?;
596 let next_payload: Result<Field, mp::MultipartError> =
597 payload.next().await.ok_or_else(|| {
598 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
599 })?;
600 match next_payload {
601 Ok(field) => {
602 let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
603 Some(mime::IMAGE) => {
604 generate_image_path(&field, StoreKind::Organization(organization.id))
605 }
606 Some(unsupported) => Err(ControllerError::new(
607 ControllerErrorType::BadRequest,
608 format!("Unsupported image Mime type: {}", unsupported),
609 None,
610 )),
611 None => Err(ControllerError::new(
612 ControllerErrorType::BadRequest,
613 "Missing image Mime type",
614 None,
615 )),
616 }?;
617 upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
618 Ok(path)
619 }
620 Err(err) => Err(ControllerError::new(
621 ControllerErrorType::InternalServerError,
622 err.to_string(),
623 None,
624 )),
625 }
626}
627
628const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
632 (mime::IMAGE, 10 * 1024 * 1024),
634 (mime::AUDIO, 100 * 1024 * 1024),
636 (mime::VIDEO, 100 * 1024 * 1024),
638 (mime::APPLICATION, 25 * 1024 * 1024),
640];
641const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
643
644fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
645 mime_type
646 .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
647 .map(|(_, size)| *size)
648 .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
649}
650
651async fn upload_field_to_storage(
653 conn: &mut PgConnection,
654 path: &Path,
655 field: mp::Field,
656 file_store: &dyn FileStore,
657 uploader: Option<AuthUser>,
658) -> Result<(), ControllerError> {
659 let mime_type = field.content_type().map(|ct| ct.type_());
661 let size_limit = get_size_limit_for_mime(mime_type);
662
663 if let Some(content_disposition) = field.content_disposition()
666 && let Some(size_str) = content_disposition
667 .parameters
668 .iter()
669 .find_map(|p| p.as_unknown("size"))
670 && let Ok(size) = size_str.parse::<u64>()
671 && size > size_limit as u64
672 {
673 return Err(ControllerError::new(
674 ControllerErrorType::BadRequest,
675 format!(
676 "File size {} exceeds limit of {} bytes for type {}",
677 size,
678 size_limit,
679 mime_type.map_or("unknown".to_string(), |m| m.to_string())
680 ),
681 None,
682 ));
683 }
684
685 let mime_type = field
687 .content_type()
688 .map(|ct| ct.to_string())
689 .unwrap_or_default();
690
691 let name = {
692 let name_ref = field.name().ok_or_else(|| {
693 ControllerError::new(
694 ControllerErrorType::BadRequest,
695 "Tried to upload a file without a file name".to_string(),
696 None,
697 )
698 })?;
699 name_ref.to_string()
700 };
701
702 let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
703
704 upload_file_to_storage(
705 conn,
706 path,
707 &name,
708 &mime_type,
709 contents,
710 file_store,
711 uploader.map(|u| u.id),
712 )
713 .await?;
714 Ok(())
715}
716pub async fn upload_certificate_svg(
717 conn: &mut PgConnection,
718 file_name: &str,
719 file: GenericPayload,
720 file_store: &dyn FileStore,
721 course_id: Uuid,
722 uploader: AuthUser,
723) -> Result<(Uuid, PathBuf), ControllerError> {
724 let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
725 let safe_path = make_filename_safe(&path);
726 let id = upload_file_to_storage(
727 conn,
728 &safe_path,
729 file_name,
730 "image/svg+xml",
731 file,
732 file_store,
733 Some(uploader.id),
734 )
735 .await?;
736 Ok((id, safe_path))
737}
738
739async fn upload_file_to_storage(
740 conn: &mut PgConnection,
741 path: &Path,
742 file_name: &str,
743 mime_type: &str,
744 file: GenericPayload,
745 file_store: &dyn FileStore,
746 uploader: Option<Uuid>,
747) -> Result<Uuid, ControllerError> {
748 let mut tx = conn.begin().await?;
749 let id = upload_file_to_storage_in_existing_transaction(
750 &mut tx, path, file_name, mime_type, file, file_store, uploader,
751 )
752 .await?;
753 tx.commit().await?;
754 Ok(id)
755}
756
757async fn upload_file_to_storage_in_existing_transaction(
758 conn: &mut PgConnection,
759 path: &Path,
760 file_name: &str,
761 mime_type: &str,
762 file: GenericPayload,
763 file_store: &dyn FileStore,
764 uploader: Option<Uuid>,
765) -> Result<Uuid, ControllerError> {
766 let path_string = path.to_str().context("invalid path")?.to_string();
767 let id = models::file_uploads::insert(conn, file_name, &path_string, mime_type, uploader, None)
768 .await?;
769 file_store.upload_stream(path, file, mime_type).await?;
770 Ok(id)
771}
772
773fn make_filename_safe(path: &PathBuf) -> PathBuf {
774 let mut path_buf = path.to_owned();
775 let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
776 path_buf.set_file_name(random_string);
777 if let Some(ext) = path.extension() {
778 let ext = ext
780 .to_str()
781 .unwrap_or("")
782 .chars()
783 .filter(|c| c.is_alphanumeric())
784 .collect::<String>();
785 path_buf.set_extension(ext);
786 }
787 path_buf
788}
789
790pub async fn delete_file_from_storage(
791 conn: &mut PgConnection,
792 id: Uuid,
793 file_store: &dyn FileStore,
794) -> Result<(), ControllerError> {
795 let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
796 file_store.delete(Path::new(&file_to_delete)).await?;
797 Ok(())
798}
799
800fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
802 let extension = match field
803 .content_type()
804 .map(|ct| ct.to_string())
805 .unwrap_or_default()
806 .as_str()
807 {
808 "audio/aac" => ".aac",
809 "audio/mpeg" => ".mp3",
810 "audio/ogg" => ".oga",
811 "audio/opus" => ".opus",
812 "audio/wav" => ".wav",
813 "audio/webm" => ".weba",
814 "audio/midi" => ".mid",
815 "audio/x-midi" => ".mid",
816 unsupported => {
817 return Err(ControllerError::new(
818 ControllerErrorType::BadRequest,
819 format!("Unsupported audio Mime type: {}", unsupported),
820 None,
821 ));
822 }
823 };
824 let mut file_name = generate_random_string(30);
825 file_name.push_str(extension);
826 let path = path(&file_name, FileType::Audio, store_kind);
827 Ok(path)
828}
829
830fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
832 let field_content = field.content_disposition().ok_or_else(|| {
833 ControllerError::new(
834 ControllerErrorType::BadRequest,
835 "No content disposition in uploaded file".to_string(),
836 None,
837 )
838 })?;
839 let field_content_name = field_content.get_filename().ok_or_else(|| {
840 ControllerError::new(
841 ControllerErrorType::BadRequest,
842 "Missing file name in content-disposition",
843 None,
844 )
845 })?;
846
847 let mut file_name = generate_random_string(30);
848 let uploaded_file_extension = get_extension_from_filename(field_content_name);
849 if let Some(extension) = uploaded_file_extension {
850 file_name.push_str(format!(".{}", extension).as_str());
851 }
852
853 let path = path(&file_name, FileType::File, store_kind);
854 Ok(path)
855}
856
857fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
859 let extension = match field
860 .content_type()
861 .map(|ct| ct.to_string())
862 .unwrap_or_default()
863 .as_str()
864 {
865 "image/jpeg" => ".jpg",
866 "image/png" => ".png",
867 "image/svg+xml" => ".svg",
868 "image/tiff" => ".tif",
869 "image/bmp" => ".bmp",
870 "image/webp" => ".webp",
871 "image/gif" => ".gif",
872 unsupported => {
873 return Err(ControllerError::new(
874 ControllerErrorType::BadRequest,
875 format!("Unsupported image Mime type: {}", unsupported),
876 None,
877 ));
878 }
879 };
880
881 let mut file_name = generate_random_string(30);
885 file_name.push_str(extension);
886 let path = path(&file_name, FileType::Image, store_kind);
887 Ok(path)
888}
889
890async fn validate_media_headers(
892 headers: &HeaderMap,
893 user: &AuthUser,
894 conn: &mut PgConnection,
895) -> ControllerResult<()> {
896 let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
897 ControllerError::new(
898 ControllerErrorType::BadRequest,
899 "Please provide a Content-Type header",
900 None,
901 )
902 })?;
903 let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
904
905 if !content_type_string.contains("multipart/form-data") {
906 return Err(ControllerError::new(
907 ControllerErrorType::BadRequest,
908 format!("Unsupported type: {}", content_type_string),
909 None,
910 ));
911 }
912
913 let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
914 ControllerError::new(
915 ControllerErrorType::BadRequest,
916 "Please provide a Content-Length in header",
917 None,
918 )
919 })?;
920 let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
921 .to_string()
922 .parse::<i32>()
923 .map_err(|original_err| {
924 ControllerError::new(
925 ControllerErrorType::InternalServerError,
926 original_err.to_string(),
927 Some(original_err.into()),
928 )
929 })?;
930
931 let mime_type = headers
932 .get("X-File-Type")
933 .map(|h| h.to_str().unwrap_or("application/octet-stream"))
934 .unwrap_or("application/octet-stream")
935 .split('/')
936 .next()
937 .map(|s| match s {
938 "image" => mime::IMAGE,
939 "audio" => mime::AUDIO,
940 "video" => mime::VIDEO,
941 "application" => mime::APPLICATION,
942 _ => mime::APPLICATION,
943 });
944 let size_limit = get_size_limit_for_mime(mime_type);
945
946 if content_length_number > size_limit {
948 return Err(ControllerError::new(
949 ControllerErrorType::BadRequest,
950 format!(
951 "File size {} exceeds limit of {} bytes for type {}",
952 content_length_number,
953 size_limit,
954 mime_type.map_or("unknown".to_string(), |m| m.to_string())
955 ),
956 None,
957 ));
958 }
959
960 let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
961 token.authorized_ok(())
962}
963
964enum FileType {
965 Image,
966 Audio,
967 File,
968}
969
970fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
971 let (base_dir, base_id) = match store_kind {
972 StoreKind::Organization(id) => ("organization", id),
973 StoreKind::Course(id) => ("course", id),
974 StoreKind::Exam(id) => ("exam", id),
975 };
976 let file_type_subdir = match file_type {
977 FileType::Image => "images",
978 FileType::Audio => "audios",
979 FileType::File => "files",
980 };
981 [base_dir, &base_id.to_string(), file_type_subdir, file_name]
982 .iter()
983 .collect()
984}