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