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 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::HashSet, path::Path};
22use std::{
23 path::PathBuf,
24 sync::{
25 Arc, Mutex,
26 atomic::{AtomicU64, Ordering},
27 },
28};
29use utoipa::ToSchema;
30
31const EXERCISE_UPLOAD_MAX_FILES: usize = 10;
32const EXERCISE_UPLOAD_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
33const EXERCISE_UPLOAD_MAX_BATCH_BYTES: u64 = 100 * 1024 * 1024;
34
35#[derive(Debug, Clone, Serialize, ToSchema)]
36pub struct ExerciseServiceUploadResultEntry {
37 pub id: String,
38 pub url: String,
39}
40
41pub struct ExerciseServiceUploadCleanup {
43 pub path: String,
44}
45
46struct ExerciseServiceUploadMetadata {
47 path: String,
48 filename: String,
49 mime_type: String,
50}
51
52pub async fn process_exercise_service_upload(
55 conn: &mut PgConnection,
56 exercise_service_slug: &str,
57 mut payload: Multipart,
58 file_store: &dyn FileStore,
59 uploaded_paths: &mut Vec<ExerciseServiceUploadCleanup>,
60 uploader: Option<AuthUser>,
61 base_url: &str,
62) -> Result<Vec<ExerciseServiceUploadResultEntry>, ControllerError> {
63 let mut results = Vec::new();
64 let mut metadata = Vec::new();
65 let mut ids = HashSet::new();
66 let batch_bytes = Arc::new(AtomicU64::new(0));
67 while let Some(item) = payload.next().await {
68 let field = item.map_err(|err| {
69 controller_err!(
70 BadRequest,
71 format!("Failed to read multipart field: {}", err),
72 anyhow::anyhow!("Multipart error: {}", err)
73 )
74 })?;
75 validate_exercise_upload_file_count(results.len())?;
76 let field_name = {
77 let name_ref = field.name().ok_or_else(|| {
78 controller_err!(
79 BadRequest,
80 "Tried to upload a multipart field without a field name or field ID"
81 .to_string()
82 )
83 })?;
84 name_ref.to_string()
85 };
86
87 validate_exercise_upload_id(&field_name, &mut ids)?;
88 let filename = validate_exercise_upload_filename(
89 field
90 .content_disposition()
91 .and_then(|disposition| disposition.get_filename()),
92 )?
93 .to_string();
94
95 let random_filename = generate_random_string(32);
96 let path = format!("{exercise_service_slug}/{random_filename}");
97 uploaded_paths.push(ExerciseServiceUploadCleanup { path: path.clone() });
98 let mime_type = field
99 .content_type()
100 .map(ToString::to_string)
101 .unwrap_or_default();
102 let (stream, stream_error) = limited_exercise_upload_stream(field, batch_bytes.clone());
103 let upload_result = file_store
104 .upload_stream(Path::new(&path), stream, &mime_type)
105 .await;
106 if let Some(error) = stream_error
107 .lock()
108 .unwrap_or_else(|poisoned| poisoned.into_inner())
109 .take()
110 {
111 return Err(error);
112 }
113 upload_result?;
114 let url = format!("{base_url}/api/v0/files/{path}");
115 results.push(ExerciseServiceUploadResultEntry {
116 id: field_name,
117 url,
118 });
119 metadata.push(ExerciseServiceUploadMetadata {
120 path,
121 filename,
122 mime_type,
123 });
124 }
125 validate_exercise_upload_not_empty(results.len())?;
126 let mut tx = conn.begin().await?;
127 for upload in metadata {
128 models::file_uploads::insert(
129 &mut tx,
130 &upload.filename,
131 &upload.path,
132 &upload.mime_type,
133 uploader.map(|user| user.id),
134 )
135 .await?;
136 }
137 tx.commit().await?;
138 Ok(results)
139}
140
141fn limited_exercise_upload_stream(
144 field: mp::Field,
145 batch_bytes: Arc<AtomicU64>,
146) -> (GenericPayload, Arc<Mutex<Option<ControllerError>>>) {
147 let per_file_bytes = Arc::new(AtomicU64::new(0));
148 let stream_error = Arc::new(Mutex::new(None));
149 let payload_stream_error = stream_error.clone();
150 let stream = Box::pin(futures::stream::try_unfold(
151 (field, per_file_bytes, batch_bytes, payload_stream_error),
152 |(mut field, per_file_bytes, batch_bytes, stream_error)| async move {
153 let Some(chunk) = field.next().await else {
154 return Ok(None);
155 };
156 let chunk = chunk.map_err(|error| anyhow::Error::msg(error.to_string()))?;
157 if let Err(error) =
158 consume_exercise_upload_bytes(&per_file_bytes, &batch_bytes, chunk.len())
159 {
160 *stream_error
161 .lock()
162 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(error);
163 return Err(anyhow::Error::msg("Exercise upload exceeds the size limit"));
164 }
165 Ok(Some((
166 chunk,
167 (field, per_file_bytes, batch_bytes, stream_error),
168 )))
169 },
170 ));
171 (stream, stream_error)
172}
173
174fn validate_exercise_upload_file_count(files_received: usize) -> Result<(), ControllerError> {
175 if files_received < EXERCISE_UPLOAD_MAX_FILES {
176 return Ok(());
177 }
178 Err(controller_err!(
179 BadRequest,
180 format!("A maximum of {EXERCISE_UPLOAD_MAX_FILES} files can be uploaded at once")
181 ))
182}
183
184fn validate_exercise_upload_id(
185 field_name: &str,
186 ids: &mut HashSet<String>,
187) -> Result<(), ControllerError> {
188 Uuid::parse_str(field_name).map_err(|_| {
189 controller_err!(
190 BadRequest,
191 "Each exercise upload field name must be a UUID".to_string()
192 )
193 })?;
194 if ids.insert(field_name.to_string()) {
195 return Ok(());
196 }
197 Err(controller_err!(
198 BadRequest,
199 "Duplicate exercise upload field id".to_string()
200 ))
201}
202
203fn validate_exercise_upload_filename(filename: Option<&str>) -> Result<&str, ControllerError> {
204 filename
205 .filter(|filename| !filename.is_empty())
206 .ok_or_else(|| {
207 controller_err!(
208 BadRequest,
209 "Every exercise upload part must be a file with a filename".to_string()
210 )
211 })
212}
213
214fn validate_exercise_upload_not_empty(files_received: usize) -> Result<(), ControllerError> {
215 if files_received > 0 {
216 return Ok(());
217 }
218 Err(controller_err!(
219 BadRequest,
220 "At least one file must be uploaded".to_string()
221 ))
222}
223
224fn consume_exercise_upload_bytes(
225 per_file_bytes: &AtomicU64,
226 batch_bytes: &AtomicU64,
227 chunk_len: usize,
228) -> Result<(), ControllerError> {
229 let chunk_len = u64::try_from(chunk_len).map_err(|error| {
230 controller_err!(
231 BadRequest,
232 "exercise upload chunk length overflow".to_string(),
233 error
234 )
235 })?;
236 let file_total = per_file_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
237 let batch_total = batch_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
238 if file_total <= EXERCISE_UPLOAD_MAX_FILE_BYTES
239 && batch_total <= EXERCISE_UPLOAD_MAX_BATCH_BYTES
240 {
241 return Ok(());
242 }
243 Err(controller_err!(
244 BadRequest,
245 "Exercise upload exceeds the 100 MiB per-file or batch limit"
246 ))
247}
248
249#[cfg(test)]
250mod exercise_upload_tests {
251 use super::*;
252
253 #[test]
254 fn exercise_upload_ids_must_be_unique_uuids() {
255 let id = Uuid::new_v4().to_string();
256 let mut ids = HashSet::new();
257
258 assert!(validate_exercise_upload_id(&id, &mut ids).is_ok());
259 assert!(
260 validate_exercise_upload_id(&id, &mut ids)
261 .unwrap_err()
262 .to_string()
263 .contains("Duplicate")
264 );
265 assert!(
266 validate_exercise_upload_id("filename.pdf", &mut ids)
267 .unwrap_err()
268 .to_string()
269 .contains("must be a UUID")
270 );
271 }
272
273 #[test]
274 fn exercise_upload_limits_are_enforced_from_streamed_bytes() {
275 let per_file = AtomicU64::new(EXERCISE_UPLOAD_MAX_FILE_BYTES - 1);
276 let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES - 1);
277 assert!(consume_exercise_upload_bytes(&per_file, &batch, 1).is_ok());
278 let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
279 assert!(matches!(
280 error.error_type(),
281 ControllerErrorType::BadRequest
282 ));
283
284 let per_file = AtomicU64::new(0);
285 let batch = AtomicU64::new(EXERCISE_UPLOAD_MAX_BATCH_BYTES);
286 let error = consume_exercise_upload_bytes(&per_file, &batch, 1).unwrap_err();
287 assert!(matches!(
288 error.error_type(),
289 ControllerErrorType::BadRequest
290 ));
291 }
292
293 #[test]
294 fn exercise_upload_rejects_an_eleventh_file() {
295 assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES - 1).is_ok());
296 assert!(validate_exercise_upload_file_count(EXERCISE_UPLOAD_MAX_FILES).is_err());
297 }
298
299 #[test]
300 fn exercise_upload_rejects_empty_and_non_file_parts() {
301 assert!(validate_exercise_upload_not_empty(0).is_err());
302 assert!(validate_exercise_upload_not_empty(1).is_ok());
303 assert!(validate_exercise_upload_filename(None).is_err());
304 assert!(validate_exercise_upload_filename(Some("")).is_err());
305 assert_eq!(
306 validate_exercise_upload_filename(Some("report.pdf")).unwrap(),
307 "report.pdf"
308 );
309 }
310}
311
312#[derive(Debug, Clone, Copy, Deserialize)]
313
314pub enum StoreKind {
315 Organization(Uuid),
316 Course(Uuid),
317 Exam(Uuid),
318}
319
320pub async fn upload_file_from_cms(
322 headers: &HeaderMap,
323 mut payload: Multipart,
324 store_kind: StoreKind,
325 file_store: &dyn FileStore,
326 conn: &mut PgConnection,
327 user: AuthUser,
328) -> Result<PathBuf, ControllerError> {
329 let file_payload = payload.next().await.ok_or_else(|| {
330 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
331 })?;
332 match file_payload {
333 Ok(field) => {
334 upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
335 }
336 Err(err) => Err(ControllerError::new(
337 ControllerErrorType::InternalServerError,
338 err.to_string(),
339 None,
340 )),
341 }
342}
343
344pub async fn upload_field_from_cms(
346 headers: &HeaderMap,
347 field: Field,
348 store_kind: StoreKind,
349 file_store: &dyn FileStore,
350 conn: &mut PgConnection,
351 user: AuthUser,
352) -> Result<PathBuf, ControllerError> {
353 validate_media_headers(headers, &user, conn).await?;
354 let path = match field.content_type().map(|ct| ct.type_()) {
355 Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
356 Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
357 _ => generate_file_path(&field, store_kind)?,
358 };
359 upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
360 Ok(path)
361}
362
363pub async fn upload_image_for_organization(
365 headers: &HeaderMap,
366 mut payload: Multipart,
367 organization: &DatabaseOrganization,
368 file_store: &Arc<dyn FileStore>,
369 user: AuthUser,
370 conn: &mut PgConnection,
371) -> Result<PathBuf, ControllerError> {
372 validate_media_headers(headers, &user, conn).await?;
373 let next_payload: Result<Field, mp::MultipartError> =
374 payload.next().await.ok_or_else(|| {
375 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
376 })?;
377 match next_payload {
378 Ok(field) => {
379 let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
380 Some(mime::IMAGE) => {
381 generate_image_path(&field, StoreKind::Organization(organization.id))
382 }
383 Some(unsupported) => Err(ControllerError::new(
384 ControllerErrorType::BadRequest,
385 format!("Unsupported image Mime type: {}", unsupported),
386 None,
387 )),
388 None => Err(ControllerError::new(
389 ControllerErrorType::BadRequest,
390 "Missing image Mime type",
391 None,
392 )),
393 }?;
394 upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
395 Ok(path)
396 }
397 Err(err) => Err(ControllerError::new(
398 ControllerErrorType::InternalServerError,
399 err.to_string(),
400 None,
401 )),
402 }
403}
404
405const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
409 (mime::IMAGE, 10 * 1024 * 1024),
411 (mime::AUDIO, 100 * 1024 * 1024),
413 (mime::VIDEO, 100 * 1024 * 1024),
415 (mime::APPLICATION, 25 * 1024 * 1024),
417];
418const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
420
421fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
422 mime_type
423 .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
424 .map(|(_, size)| *size)
425 .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
426}
427
428async fn upload_field_to_storage(
430 conn: &mut PgConnection,
431 path: &Path,
432 field: mp::Field,
433 file_store: &dyn FileStore,
434 uploader: Option<AuthUser>,
435) -> Result<(), ControllerError> {
436 let mime_type = field.content_type().map(|ct| ct.type_());
438 let size_limit = get_size_limit_for_mime(mime_type);
439
440 if let Some(content_disposition) = field.content_disposition()
443 && let Some(size_str) = content_disposition
444 .parameters
445 .iter()
446 .find_map(|p| p.as_unknown("size"))
447 && let Ok(size) = size_str.parse::<u64>()
448 && size > size_limit as u64
449 {
450 return Err(ControllerError::new(
451 ControllerErrorType::BadRequest,
452 format!(
453 "File size {} exceeds limit of {} bytes for type {}",
454 size,
455 size_limit,
456 mime_type.map_or("unknown".to_string(), |m| m.to_string())
457 ),
458 None,
459 ));
460 }
461
462 let mime_type = field
464 .content_type()
465 .map(|ct| ct.to_string())
466 .unwrap_or_default();
467
468 let name = {
469 let name_ref = field.name().ok_or_else(|| {
470 ControllerError::new(
471 ControllerErrorType::BadRequest,
472 "Tried to upload a file without a file name".to_string(),
473 None,
474 )
475 })?;
476 name_ref.to_string()
477 };
478
479 let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
480
481 upload_file_to_storage(
482 conn,
483 path,
484 &name,
485 &mime_type,
486 contents,
487 file_store,
488 uploader.map(|u| u.id),
489 )
490 .await?;
491 Ok(())
492}
493pub async fn upload_certificate_svg(
494 conn: &mut PgConnection,
495 file_name: &str,
496 file: GenericPayload,
497 file_store: &dyn FileStore,
498 course_id: Uuid,
499 uploader: AuthUser,
500) -> Result<(Uuid, PathBuf), ControllerError> {
501 let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
502 let safe_path = make_filename_safe(&path);
503 let id = upload_file_to_storage(
504 conn,
505 &safe_path,
506 file_name,
507 "image/svg+xml",
508 file,
509 file_store,
510 Some(uploader.id),
511 )
512 .await?;
513 Ok((id, safe_path))
514}
515
516pub struct ExerciseTaskInfo<'a> {
517 pub course_id: Uuid,
518 pub exercise: &'a Exercise,
519 pub exercise_slide: &'a ExerciseSlide,
520 pub exercise_task: &'a ExerciseTask,
521}
522
523pub async fn upload_exercise_archive(
524 conn: &mut PgConnection,
525 file: GenericPayload,
526 file_store: &dyn FileStore,
527 exercise: ExerciseTaskInfo<'_>,
528 mime: Mime,
529 uploader: Uuid,
530) -> Result<(Uuid, PathBuf), ControllerError> {
531 let file_name = &exercise.exercise.name;
532 let path = nested_path(
533 &[
534 "user-exercise-uploads",
535 "exercise",
536 &exercise.exercise.id.to_string(),
537 "slide",
538 &exercise.exercise_slide.id.to_string(),
539 "task",
540 &exercise.exercise_task.id.to_string(),
541 file_name,
542 ],
543 FileType::File,
544 StoreKind::Course(exercise.course_id),
545 );
546 let safe_path = make_filename_safe(&path);
547 let id = upload_file_to_storage(
548 conn,
549 &safe_path,
550 file_name,
551 mime.as_ref(),
552 file,
553 file_store,
554 Some(uploader),
555 )
556 .await?;
557 Ok((id, safe_path))
558}
559
560async fn upload_file_to_storage(
561 conn: &mut PgConnection,
562 path: &Path,
563 file_name: &str,
564 mime_type: &str,
565 file: GenericPayload,
566 file_store: &dyn FileStore,
567 uploader: Option<Uuid>,
568) -> Result<Uuid, ControllerError> {
569 let mut tx = conn.begin().await?;
570 let id = upload_file_to_storage_in_existing_transaction(
571 &mut tx, path, file_name, mime_type, file, file_store, uploader,
572 )
573 .await?;
574 tx.commit().await?;
575 Ok(id)
576}
577
578async fn upload_file_to_storage_in_existing_transaction(
579 conn: &mut PgConnection,
580 path: &Path,
581 file_name: &str,
582 mime_type: &str,
583 file: GenericPayload,
584 file_store: &dyn FileStore,
585 uploader: Option<Uuid>,
586) -> Result<Uuid, ControllerError> {
587 let path_string = path.to_str().context("invalid path")?.to_string();
588 let id =
589 models::file_uploads::insert(conn, file_name, &path_string, mime_type, uploader).await?;
590 file_store.upload_stream(path, file, mime_type).await?;
591 Ok(id)
592}
593
594fn make_filename_safe(path: &PathBuf) -> PathBuf {
595 let mut path_buf = path.to_owned();
596 let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
597 path_buf.set_file_name(random_string);
598 if let Some(ext) = path.extension() {
599 let ext = ext
601 .to_str()
602 .unwrap_or("")
603 .chars()
604 .filter(|c| c.is_alphanumeric())
605 .collect::<String>();
606 path_buf.set_extension(ext);
607 }
608 path_buf
609}
610
611pub async fn delete_file_from_storage(
612 conn: &mut PgConnection,
613 id: Uuid,
614 file_store: &dyn FileStore,
615) -> Result<(), ControllerError> {
616 let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
617 file_store.delete(Path::new(&file_to_delete)).await?;
618 Ok(())
619}
620
621fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
623 let extension = match field
624 .content_type()
625 .map(|ct| ct.to_string())
626 .unwrap_or_default()
627 .as_str()
628 {
629 "audio/aac" => ".aac",
630 "audio/mpeg" => ".mp3",
631 "audio/ogg" => ".oga",
632 "audio/opus" => ".opus",
633 "audio/wav" => ".wav",
634 "audio/webm" => ".weba",
635 "audio/midi" => ".mid",
636 "audio/x-midi" => ".mid",
637 unsupported => {
638 return Err(ControllerError::new(
639 ControllerErrorType::BadRequest,
640 format!("Unsupported audio Mime type: {}", unsupported),
641 None,
642 ));
643 }
644 };
645 let mut file_name = generate_random_string(30);
646 file_name.push_str(extension);
647 let path = path(&file_name, FileType::Audio, store_kind);
648 Ok(path)
649}
650
651fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
653 let field_content = field.content_disposition().ok_or_else(|| {
654 ControllerError::new(
655 ControllerErrorType::BadRequest,
656 "No content disposition in uploaded file".to_string(),
657 None,
658 )
659 })?;
660 let field_content_name = field_content.get_filename().ok_or_else(|| {
661 ControllerError::new(
662 ControllerErrorType::BadRequest,
663 "Missing file name in content-disposition",
664 None,
665 )
666 })?;
667
668 let mut file_name = generate_random_string(30);
669 let uploaded_file_extension = get_extension_from_filename(field_content_name);
670 if let Some(extension) = uploaded_file_extension {
671 file_name.push_str(format!(".{}", extension).as_str());
672 }
673
674 let path = path(&file_name, FileType::File, store_kind);
675 Ok(path)
676}
677
678fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
680 let extension = match field
681 .content_type()
682 .map(|ct| ct.to_string())
683 .unwrap_or_default()
684 .as_str()
685 {
686 "image/jpeg" => ".jpg",
687 "image/png" => ".png",
688 "image/svg+xml" => ".svg",
689 "image/tiff" => ".tif",
690 "image/bmp" => ".bmp",
691 "image/webp" => ".webp",
692 "image/gif" => ".gif",
693 unsupported => {
694 return Err(ControllerError::new(
695 ControllerErrorType::BadRequest,
696 format!("Unsupported image Mime type: {}", unsupported),
697 None,
698 ));
699 }
700 };
701
702 let mut file_name = generate_random_string(30);
706 file_name.push_str(extension);
707 let path = path(&file_name, FileType::Image, store_kind);
708 Ok(path)
709}
710
711async fn validate_media_headers(
713 headers: &HeaderMap,
714 user: &AuthUser,
715 conn: &mut PgConnection,
716) -> ControllerResult<()> {
717 let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
718 ControllerError::new(
719 ControllerErrorType::BadRequest,
720 "Please provide a Content-Type header",
721 None,
722 )
723 })?;
724 let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
725
726 if !content_type_string.contains("multipart/form-data") {
727 return Err(ControllerError::new(
728 ControllerErrorType::BadRequest,
729 format!("Unsupported type: {}", content_type_string),
730 None,
731 ));
732 }
733
734 let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
735 ControllerError::new(
736 ControllerErrorType::BadRequest,
737 "Please provide a Content-Length in header",
738 None,
739 )
740 })?;
741 let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
742 .to_string()
743 .parse::<i32>()
744 .map_err(|original_err| {
745 ControllerError::new(
746 ControllerErrorType::InternalServerError,
747 original_err.to_string(),
748 Some(original_err.into()),
749 )
750 })?;
751
752 let mime_type = headers
753 .get("X-File-Type")
754 .map(|h| h.to_str().unwrap_or("application/octet-stream"))
755 .unwrap_or("application/octet-stream")
756 .split('/')
757 .next()
758 .map(|s| match s {
759 "image" => mime::IMAGE,
760 "audio" => mime::AUDIO,
761 "video" => mime::VIDEO,
762 "application" => mime::APPLICATION,
763 _ => mime::APPLICATION,
764 });
765 let size_limit = get_size_limit_for_mime(mime_type);
766
767 if content_length_number > size_limit {
769 return Err(ControllerError::new(
770 ControllerErrorType::BadRequest,
771 format!(
772 "File size {} exceeds limit of {} bytes for type {}",
773 content_length_number,
774 size_limit,
775 mime_type.map_or("unknown".to_string(), |m| m.to_string())
776 ),
777 None,
778 ));
779 }
780
781 let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
782 token.authorized_ok(())
783}
784
785enum FileType {
786 Image,
787 Audio,
788 File,
789}
790
791fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
792 nested_path(&[file_name], file_type, store_kind)
793}
794
795fn nested_path(components: &[&str], file_type: FileType, store_kind: StoreKind) -> PathBuf {
796 let (base_dir, base_id) = match store_kind {
797 StoreKind::Organization(id) => ("organization", id),
798 StoreKind::Course(id) => ("course", id),
799 StoreKind::Exam(id) => ("exam", id),
800 };
801 let file_type_subdir = match file_type {
802 FileType::Image => "images",
803 FileType::Audio => "audios",
804 FileType::File => "files",
805 };
806 [base_dir, &base_id.to_string(), file_type_subdir]
807 .iter()
808 .chain(components)
809 .collect()
810}