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 path: String,
120 filename: String,
121 mime_type: String,
122 size_bytes: i64,
123 url: String,
124}
125
126pub async fn process_exercise_service_upload(
133 conn: &mut PgConnection,
134 exercise_service_slug: &str,
135 payload: Multipart,
136 file_store: &dyn FileStore,
137 uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
138 uploader: Option<Uuid>,
139 base_url: &str,
140) -> Result<Vec<ExerciseServiceUpload>, ControllerError> {
141 let streamed = stream_exercise_service_upload(
142 exercise_service_slug,
143 payload,
144 file_store,
145 uploaded_paths,
146 base_url,
147 )
148 .await?;
149 let mut tx = conn.begin().await?;
150 let uploads = record_exercise_service_upload(&mut tx, streamed, uploader).await?;
151 let file_upload_ids: Vec<Uuid> = uploads.iter().map(|upload| upload.entry.id).collect();
152 models::exercise_spec_uploads::insert_many(
155 &mut tx,
156 exercise_service_slug,
157 uploader,
158 &file_upload_ids,
159 )
160 .await?;
161 tx.commit().await?;
162 Ok(uploads)
163}
164
165pub struct StreamedExerciseServiceUpload {
167 parts: Vec<ExerciseServiceUploadMetadata>,
168}
169
170pub async fn stream_exercise_service_upload(
177 path_prefix: &str,
178 mut payload: Multipart,
179 file_store: &dyn FileStore,
180 uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
181 base_url: &str,
182) -> Result<StreamedExerciseServiceUpload, ControllerError> {
183 let mut parts = Vec::new();
184 let mut ids = HashSet::new();
185 let batch_bytes = Arc::new(AtomicU64::new(0));
186 while let Some(item) = payload.next().await {
187 let field = item.map_err(|err| {
188 controller_err!(
189 BadRequest,
190 format!("Failed to read multipart field: {}", err),
191 anyhow::anyhow!("Multipart error: {}", err)
192 )
193 })?;
194 validate_exercise_upload_file_count(parts.len())?;
195 let field_name = {
196 let name_ref = field.name().ok_or_else(|| {
197 controller_err!(
198 BadRequest,
199 "Tried to upload a multipart field without a field name or field ID"
200 .to_string()
201 )
202 })?;
203 name_ref.to_string()
204 };
205
206 validate_exercise_upload_id(&field_name, &mut ids)?;
207 let filename = validate_exercise_upload_filename(
208 field
209 .content_disposition()
210 .and_then(|disposition| disposition.get_filename()),
211 )?
212 .to_string();
213
214 let random_filename = generate_random_string(32);
215 let path = format!("{path_prefix}/{random_filename}");
216 uploaded_paths.push(ExerciseServiceUploadCleanup { path: path.clone() });
217 let mime_type = field
218 .content_type()
219 .map(ToString::to_string)
220 .unwrap_or_default();
221 let (stream, stream_error, uploaded_bytes) =
222 limited_exercise_upload_stream(field, batch_bytes.clone());
223 let upload_result = file_store
224 .upload_stream(Path::new(&path), stream, &mime_type)
225 .await;
226 if let Some(error) = stream_error
227 .lock()
228 .unwrap_or_else(|poisoned| poisoned.into_inner())
229 .take()
230 {
231 return Err(error);
232 }
233 upload_result?;
234 let url = format!("{base_url}/api/v0/files/{path}");
235 parts.push(ExerciseServiceUploadMetadata {
236 path,
237 filename,
238 mime_type,
239 size_bytes: uploaded_bytes.load(Ordering::SeqCst) as i64,
240 url,
241 });
242 }
243 validate_exercise_upload_not_empty(parts.len())?;
244 Ok(StreamedExerciseServiceUpload { parts })
245}
246
247pub async fn record_exercise_service_upload(
252 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
253 streamed: StreamedExerciseServiceUpload,
254 uploader: Option<Uuid>,
255) -> Result<Vec<ExerciseServiceUpload>, ControllerError> {
256 let StreamedExerciseServiceUpload { parts } = streamed;
257 let mut uploads = Vec::with_capacity(parts.len());
258 for part in parts {
259 let file_upload_id = models::file_uploads::insert(
260 tx,
261 &part.filename,
262 &part.path,
263 &part.mime_type,
264 uploader,
265 Some(part.size_bytes),
266 )
267 .await?;
268 uploads.push(ExerciseServiceUpload {
269 entry: ExerciseServiceUploadResultEntry {
270 id: file_upload_id,
271 url: part.url,
272 },
273 name: part.filename,
274 mime: part.mime_type,
275 size_bytes: part.size_bytes,
276 });
277 }
278 Ok(uploads)
279}
280
281fn limited_exercise_upload_stream(
285 field: mp::Field,
286 batch_bytes: Arc<AtomicU64>,
287) -> (
288 GenericPayload,
289 Arc<Mutex<Option<ControllerError>>>,
290 Arc<AtomicU64>,
291) {
292 let per_file_bytes = Arc::new(AtomicU64::new(0));
293 let stream_error = Arc::new(Mutex::new(None));
294 let payload_stream_error = stream_error.clone();
295 let payload_per_file_bytes = per_file_bytes.clone();
296 let stream = Box::pin(futures::stream::try_unfold(
297 (
298 field,
299 payload_per_file_bytes,
300 batch_bytes,
301 payload_stream_error,
302 ),
303 |(mut field, per_file_bytes, batch_bytes, stream_error)| async move {
304 let Some(chunk) = field.next().await else {
305 return Ok(None);
306 };
307 let chunk = chunk.map_err(|error| anyhow::Error::msg(error.to_string()))?;
308 if let Err(error) =
309 consume_exercise_upload_bytes(&per_file_bytes, &batch_bytes, chunk.len())
310 {
311 *stream_error
312 .lock()
313 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(error);
314 return Err(anyhow::Error::msg("Exercise upload exceeds the size limit"));
315 }
316 Ok(Some((
317 chunk,
318 (field, per_file_bytes, batch_bytes, stream_error),
319 )))
320 },
321 ));
322 (stream, stream_error, per_file_bytes)
323}
324
325fn validate_exercise_upload_file_count(files_received: usize) -> Result<(), ControllerError> {
326 if files_received < EXERCISE_UPLOAD_MAX_FILES {
327 return Ok(());
328 }
329 Err(controller_err!(
330 BadRequest,
331 format!("A maximum of {EXERCISE_UPLOAD_MAX_FILES} files can be uploaded at once")
332 ))
333}
334
335fn validate_exercise_upload_id(
336 field_name: &str,
337 ids: &mut HashSet<String>,
338) -> Result<(), ControllerError> {
339 Uuid::parse_str(field_name).map_err(|_| {
340 controller_err!(
341 BadRequest,
342 "Each exercise upload field name must be a UUID".to_string()
343 )
344 })?;
345 if ids.insert(field_name.to_string()) {
346 return Ok(());
347 }
348 Err(controller_err!(
349 BadRequest,
350 "Duplicate exercise upload field id".to_string()
351 ))
352}
353
354fn validate_exercise_upload_filename(filename: Option<&str>) -> Result<&str, ControllerError> {
355 filename
356 .filter(|filename| !filename.is_empty())
357 .ok_or_else(|| {
358 controller_err!(
359 BadRequest,
360 "Every exercise upload part must be a file with a filename".to_string()
361 )
362 })
363}
364
365fn validate_exercise_upload_not_empty(files_received: usize) -> Result<(), ControllerError> {
366 if files_received > 0 {
367 return Ok(());
368 }
369 Err(controller_err!(
370 BadRequest,
371 "At least one file must be uploaded".to_string()
372 ))
373}
374
375fn consume_exercise_upload_bytes(
376 per_file_bytes: &AtomicU64,
377 batch_bytes: &AtomicU64,
378 chunk_len: usize,
379) -> Result<(), ControllerError> {
380 let chunk_len = u64::try_from(chunk_len).map_err(|error| {
381 controller_err!(
382 BadRequest,
383 "exercise upload chunk length overflow".to_string(),
384 error
385 )
386 })?;
387 let file_total = per_file_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
388 let batch_total = batch_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
389 if file_total <= EXERCISE_UPLOAD_MAX_FILE_BYTES
390 && batch_total <= EXERCISE_UPLOAD_MAX_BATCH_BYTES
391 {
392 return Ok(());
393 }
394 Err(controller_err!(
395 BadRequest,
396 "Exercise upload exceeds the 100 MiB per-file or batch limit"
397 ))
398}
399
400#[cfg(test)]
401mod exercise_upload_tests {
402 use super::*;
403
404 #[test]
405 fn exercise_upload_ids_must_be_unique_uuids() {
406 let id = Uuid::new_v4().to_string();
407 let mut ids = HashSet::new();
408
409 assert!(validate_exercise_upload_id(&id, &mut ids).is_ok());
410 assert!(
411 validate_exercise_upload_id(&id, &mut ids)
412 .unwrap_err()
413 .to_string()
414 .contains("Duplicate")
415 );
416 assert!(
417 validate_exercise_upload_id("filename.pdf", &mut ids)
418 .unwrap_err()
419 .to_string()
420 .contains("must be a UUID")
421 );
422 }
423
424 #[test]
425 fn exercise_upload_limits_are_enforced_from_streamed_bytes() {
426 let per_file = AtomicU64::new(EXERCISE_UPLOAD_MAX_FILE_BYTES - 1);
427 let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES - 1);
428 assert!(consume_exercise_upload_bytes(&per_file, &batch, 1).is_ok());
429 let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
430 assert!(matches!(
431 error.error_type(),
432 ControllerErrorType::BadRequest
433 ));
434
435 let per_file = AtomicU64::new(0);
436 let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES);
437 let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
438 assert!(matches!(
439 error.error_type(),
440 ControllerErrorType::BadRequest
441 ));
442 }
443
444 #[test]
445 fn exercise_upload_rejects_an_eleventh_file() {
446 assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES - 1).is_ok());
447 assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES).is_err());
448 }
449
450 #[test]
451 fn exercise_upload_rejects_empty_and_non_file_parts() {
452 assert!(validate_exercise_upload_not_empty(0).is_err());
453 assert!(validate_exercise_upload_not_empty(1).is_ok());
454 assert!(validate_exercise_upload_filename(None).is_err());
455 assert!(validate_exercise_upload_filename(Some("")).is_err());
456 assert_eq!(
457 validate_exercise_upload_filename(Some("report.pdf")).unwrap(),
458 "report.pdf"
459 );
460 }
461}
462
463#[derive(Debug, Clone, Copy, Deserialize)]
464
465pub enum StoreKind {
466 Organization(Uuid),
467 Course(Uuid),
468 Exam(Uuid),
469}
470
471pub async fn upload_file_from_cms(
473 headers: &HeaderMap,
474 mut payload: Multipart,
475 store_kind: StoreKind,
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) => {
485 upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
486 }
487 Err(err) => Err(ControllerError::new(
488 ControllerErrorType::InternalServerError,
489 err.to_string(),
490 None,
491 )),
492 }
493}
494
495pub async fn upload_field_from_cms(
497 headers: &HeaderMap,
498 field: Field,
499 store_kind: StoreKind,
500 file_store: &dyn FileStore,
501 conn: &mut PgConnection,
502 user: AuthUser,
503) -> Result<PathBuf, ControllerError> {
504 validate_media_headers(headers, &user, conn).await?;
505 let path = match field.content_type().map(|ct| ct.type_()) {
506 Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
507 Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
508 _ => generate_file_path(&field, store_kind)?,
509 };
510 upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
511 Ok(path)
512}
513
514pub async fn upload_image_for_organization(
516 headers: &HeaderMap,
517 mut payload: Multipart,
518 organization: &DatabaseOrganization,
519 file_store: &Arc<dyn FileStore>,
520 user: AuthUser,
521 conn: &mut PgConnection,
522) -> Result<PathBuf, ControllerError> {
523 validate_media_headers(headers, &user, conn).await?;
524 let next_payload: Result<Field, mp::MultipartError> =
525 payload.next().await.ok_or_else(|| {
526 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
527 })?;
528 match next_payload {
529 Ok(field) => {
530 let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
531 Some(mime::IMAGE) => {
532 generate_image_path(&field, StoreKind::Organization(organization.id))
533 }
534 Some(unsupported) => Err(ControllerError::new(
535 ControllerErrorType::BadRequest,
536 format!("Unsupported image Mime type: {}", unsupported),
537 None,
538 )),
539 None => Err(ControllerError::new(
540 ControllerErrorType::BadRequest,
541 "Missing image Mime type",
542 None,
543 )),
544 }?;
545 upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
546 Ok(path)
547 }
548 Err(err) => Err(ControllerError::new(
549 ControllerErrorType::InternalServerError,
550 err.to_string(),
551 None,
552 )),
553 }
554}
555
556const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
560 (mime::IMAGE, 10 * 1024 * 1024),
562 (mime::AUDIO, 100 * 1024 * 1024),
564 (mime::VIDEO, 100 * 1024 * 1024),
566 (mime::APPLICATION, 25 * 1024 * 1024),
568];
569const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
571
572fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
573 mime_type
574 .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
575 .map(|(_, size)| *size)
576 .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
577}
578
579async fn upload_field_to_storage(
581 conn: &mut PgConnection,
582 path: &Path,
583 field: mp::Field,
584 file_store: &dyn FileStore,
585 uploader: Option<AuthUser>,
586) -> Result<(), ControllerError> {
587 let mime_type = field.content_type().map(|ct| ct.type_());
589 let size_limit = get_size_limit_for_mime(mime_type);
590
591 if let Some(content_disposition) = field.content_disposition()
594 && let Some(size_str) = content_disposition
595 .parameters
596 .iter()
597 .find_map(|p| p.as_unknown("size"))
598 && let Ok(size) = size_str.parse::<u64>()
599 && size > size_limit as u64
600 {
601 return Err(ControllerError::new(
602 ControllerErrorType::BadRequest,
603 format!(
604 "File size {} exceeds limit of {} bytes for type {}",
605 size,
606 size_limit,
607 mime_type.map_or("unknown".to_string(), |m| m.to_string())
608 ),
609 None,
610 ));
611 }
612
613 let mime_type = field
615 .content_type()
616 .map(|ct| ct.to_string())
617 .unwrap_or_default();
618
619 let name = {
620 let name_ref = field.name().ok_or_else(|| {
621 ControllerError::new(
622 ControllerErrorType::BadRequest,
623 "Tried to upload a file without a file name".to_string(),
624 None,
625 )
626 })?;
627 name_ref.to_string()
628 };
629
630 let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
631
632 upload_file_to_storage(
633 conn,
634 path,
635 &name,
636 &mime_type,
637 contents,
638 file_store,
639 uploader.map(|u| u.id),
640 )
641 .await?;
642 Ok(())
643}
644pub async fn upload_certificate_svg(
645 conn: &mut PgConnection,
646 file_name: &str,
647 file: GenericPayload,
648 file_store: &dyn FileStore,
649 course_id: Uuid,
650 uploader: AuthUser,
651) -> Result<(Uuid, PathBuf), ControllerError> {
652 let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
653 let safe_path = make_filename_safe(&path);
654 let id = upload_file_to_storage(
655 conn,
656 &safe_path,
657 file_name,
658 "image/svg+xml",
659 file,
660 file_store,
661 Some(uploader.id),
662 )
663 .await?;
664 Ok((id, safe_path))
665}
666
667async fn upload_file_to_storage(
668 conn: &mut PgConnection,
669 path: &Path,
670 file_name: &str,
671 mime_type: &str,
672 file: GenericPayload,
673 file_store: &dyn FileStore,
674 uploader: Option<Uuid>,
675) -> Result<Uuid, ControllerError> {
676 let mut tx = conn.begin().await?;
677 let id = upload_file_to_storage_in_existing_transaction(
678 &mut tx, path, file_name, mime_type, file, file_store, uploader,
679 )
680 .await?;
681 tx.commit().await?;
682 Ok(id)
683}
684
685async fn upload_file_to_storage_in_existing_transaction(
686 conn: &mut PgConnection,
687 path: &Path,
688 file_name: &str,
689 mime_type: &str,
690 file: GenericPayload,
691 file_store: &dyn FileStore,
692 uploader: Option<Uuid>,
693) -> Result<Uuid, ControllerError> {
694 let path_string = path.to_str().context("invalid path")?.to_string();
695 let id = models::file_uploads::insert(conn, file_name, &path_string, mime_type, uploader, None)
696 .await?;
697 file_store.upload_stream(path, file, mime_type).await?;
698 Ok(id)
699}
700
701fn make_filename_safe(path: &PathBuf) -> PathBuf {
702 let mut path_buf = path.to_owned();
703 let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
704 path_buf.set_file_name(random_string);
705 if let Some(ext) = path.extension() {
706 let ext = ext
708 .to_str()
709 .unwrap_or("")
710 .chars()
711 .filter(|c| c.is_alphanumeric())
712 .collect::<String>();
713 path_buf.set_extension(ext);
714 }
715 path_buf
716}
717
718pub async fn delete_file_from_storage(
719 conn: &mut PgConnection,
720 id: Uuid,
721 file_store: &dyn FileStore,
722) -> Result<(), ControllerError> {
723 let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
724 file_store.delete(Path::new(&file_to_delete)).await?;
725 Ok(())
726}
727
728fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
730 let extension = match field
731 .content_type()
732 .map(|ct| ct.to_string())
733 .unwrap_or_default()
734 .as_str()
735 {
736 "audio/aac" => ".aac",
737 "audio/mpeg" => ".mp3",
738 "audio/ogg" => ".oga",
739 "audio/opus" => ".opus",
740 "audio/wav" => ".wav",
741 "audio/webm" => ".weba",
742 "audio/midi" => ".mid",
743 "audio/x-midi" => ".mid",
744 unsupported => {
745 return Err(ControllerError::new(
746 ControllerErrorType::BadRequest,
747 format!("Unsupported audio Mime type: {}", unsupported),
748 None,
749 ));
750 }
751 };
752 let mut file_name = generate_random_string(30);
753 file_name.push_str(extension);
754 let path = path(&file_name, FileType::Audio, store_kind);
755 Ok(path)
756}
757
758fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
760 let field_content = field.content_disposition().ok_or_else(|| {
761 ControllerError::new(
762 ControllerErrorType::BadRequest,
763 "No content disposition in uploaded file".to_string(),
764 None,
765 )
766 })?;
767 let field_content_name = field_content.get_filename().ok_or_else(|| {
768 ControllerError::new(
769 ControllerErrorType::BadRequest,
770 "Missing file name in content-disposition",
771 None,
772 )
773 })?;
774
775 let mut file_name = generate_random_string(30);
776 let uploaded_file_extension = get_extension_from_filename(field_content_name);
777 if let Some(extension) = uploaded_file_extension {
778 file_name.push_str(format!(".{}", extension).as_str());
779 }
780
781 let path = path(&file_name, FileType::File, store_kind);
782 Ok(path)
783}
784
785fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
787 let extension = match field
788 .content_type()
789 .map(|ct| ct.to_string())
790 .unwrap_or_default()
791 .as_str()
792 {
793 "image/jpeg" => ".jpg",
794 "image/png" => ".png",
795 "image/svg+xml" => ".svg",
796 "image/tiff" => ".tif",
797 "image/bmp" => ".bmp",
798 "image/webp" => ".webp",
799 "image/gif" => ".gif",
800 unsupported => {
801 return Err(ControllerError::new(
802 ControllerErrorType::BadRequest,
803 format!("Unsupported image Mime type: {}", unsupported),
804 None,
805 ));
806 }
807 };
808
809 let mut file_name = generate_random_string(30);
813 file_name.push_str(extension);
814 let path = path(&file_name, FileType::Image, store_kind);
815 Ok(path)
816}
817
818async fn validate_media_headers(
820 headers: &HeaderMap,
821 user: &AuthUser,
822 conn: &mut PgConnection,
823) -> ControllerResult<()> {
824 let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
825 ControllerError::new(
826 ControllerErrorType::BadRequest,
827 "Please provide a Content-Type header",
828 None,
829 )
830 })?;
831 let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
832
833 if !content_type_string.contains("multipart/form-data") {
834 return Err(ControllerError::new(
835 ControllerErrorType::BadRequest,
836 format!("Unsupported type: {}", content_type_string),
837 None,
838 ));
839 }
840
841 let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
842 ControllerError::new(
843 ControllerErrorType::BadRequest,
844 "Please provide a Content-Length in header",
845 None,
846 )
847 })?;
848 let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
849 .to_string()
850 .parse::<i32>()
851 .map_err(|original_err| {
852 ControllerError::new(
853 ControllerErrorType::InternalServerError,
854 original_err.to_string(),
855 Some(original_err.into()),
856 )
857 })?;
858
859 let mime_type = headers
860 .get("X-File-Type")
861 .map(|h| h.to_str().unwrap_or("application/octet-stream"))
862 .unwrap_or("application/octet-stream")
863 .split('/')
864 .next()
865 .map(|s| match s {
866 "image" => mime::IMAGE,
867 "audio" => mime::AUDIO,
868 "video" => mime::VIDEO,
869 "application" => mime::APPLICATION,
870 _ => mime::APPLICATION,
871 });
872 let size_limit = get_size_limit_for_mime(mime_type);
873
874 if content_length_number > size_limit {
876 return Err(ControllerError::new(
877 ControllerErrorType::BadRequest,
878 format!(
879 "File size {} exceeds limit of {} bytes for type {}",
880 content_length_number,
881 size_limit,
882 mime_type.map_or("unknown".to_string(), |m| m.to_string())
883 ),
884 None,
885 ));
886 }
887
888 let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
889 token.authorized_ok(())
890}
891
892enum FileType {
893 Image,
894 Audio,
895 File,
896}
897
898fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
899 let (base_dir, base_id) = match store_kind {
900 StoreKind::Organization(id) => ("organization", id),
901 StoreKind::Course(id) => ("course", id),
902 StoreKind::Exam(id) => ("exam", id),
903 };
904 let file_type_subdir = match file_type {
905 FileType::Image => "images",
906 FileType::Audio => "audios",
907 FileType::File => "files",
908 };
909 [base_dir, &base_id.to_string(), file_type_subdir, file_name]
910 .iter()
911 .collect()
912}