1use super::helpers::file_uploading;
6use crate::domain::models_requests::{DownloadClaim, JwtKey};
7pub use crate::domain::{authorization::AuthorizationToken, models_requests::UploadClaim};
8use crate::prelude::*;
9use actix_files::NamedFile;
10use headless_lms_utils::file_store::attachment_content_disposition;
11use std::path::{Component, Path};
12use tokio::fs::read;
13use utoipa::{OpenApi, PartialSchema, ToSchema};
14
15struct ExerciseUploadBinary;
17
18impl PartialSchema for ExerciseUploadBinary {
19 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
20 utoipa::openapi::schema::ObjectBuilder::new()
21 .schema_type(utoipa::openapi::schema::Type::String)
22 .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
23 utoipa::openapi::KnownFormat::Binary,
24 )))
25 .into()
26 }
27}
28
29impl ToSchema for ExerciseUploadBinary {}
30
31#[derive(OpenApi)]
32#[openapi(paths(upload_from_exercise_service, upload_answer_files))]
33pub(crate) struct FilesApiDoc;
34
35#[derive(OpenApi)]
39#[openapi(paths(upload_from_exercise_service))]
40pub(crate) struct CmsFilesApiDoc;
41#[derive(Debug, Deserialize)]
42struct FileQuery {
43 download: Option<String>,
45 name: Option<String>,
47}
48
49fn download_file_name(
53 requested_name: Option<&str>,
54 stored_name: Option<&str>,
55 path: &Path,
56) -> String {
57 let stem_name = path
58 .file_name()
59 .map(|name| name.to_string_lossy().to_string())
60 .unwrap_or_default();
61 let name = [requested_name, stored_name]
62 .into_iter()
63 .flatten()
64 .map(str::trim)
65 .find(|name| !name.is_empty())
66 .unwrap_or(&stem_name);
67 let extension = path
68 .extension()
69 .or_else(|| stored_name.and_then(|stored_name| Path::new(stored_name).extension()))
70 .map(|extension| extension.to_string_lossy());
71 match extension {
72 Some(extension)
73 if !name
74 .to_lowercase()
75 .ends_with(&format!(".{}", extension.to_lowercase())) =>
76 {
77 format!("{name}.{extension}")
78 }
79 _ => name.to_string(),
80 }
81}
82
83#[instrument(skip(file_store))]
112#[allow(clippy::async_yields_async)]
113async fn redirect_to_storage_service(
114 tail: web::Path<String>,
115 query: web::Query<FileQuery>,
116 pool: web::Data<PgPool>,
117 file_store: web::Data<dyn FileStore>,
118) -> HttpResponse {
119 let inner = tail.into_inner();
120 let tail_path = Path::new(&inner);
121
122 let url = if query.download.is_some() {
123 let stored_name = match pool.acquire().await {
124 Ok(mut conn) => models::file_uploads::get_details_by_path(&mut conn, &inner)
125 .await
126 .ok()
127 .map(|details| details.name),
128 Err(_) => None,
129 };
130 let file_name =
131 download_file_name(query.name.as_deref(), stored_name.as_deref(), tail_path);
132 file_store
133 .get_direct_attachment_download_url(tail_path, &file_name)
134 .await
135 } else {
136 file_store.get_direct_download_url(tail_path).await
137 };
138 match url {
139 Ok(url) => HttpResponse::Found()
140 .append_header(("location", url))
141 .append_header(("cache-control", "max-age=300, private"))
142 .finish(),
143 Err(e) => {
144 error!("Could not get file {:?}", e);
145 HttpResponse::NotFound()
146 .append_header(("cache-control", "max-age=300, private"))
147 .finish()
148 }
149 }
150}
151
152#[instrument(skip(req))]
165async fn serve_upload(
166 req: HttpRequest,
167 query: web::Query<FileQuery>,
168 pool: web::Data<PgPool>,
169) -> ControllerResult<HttpResponse> {
170 let mut conn = pool.acquire().await?;
171
172 let base_folder = Path::new("uploads");
174 let relative_path = req.match_info().query("tail");
175 let requested_path = Path::new(relative_path);
176 if requested_path.is_absolute()
177 || requested_path.components().any(|component| {
178 matches!(
179 component,
180 Component::ParentDir | Component::RootDir | Component::Prefix(_)
181 )
182 })
183 {
184 return Err(controller_err!(
185 BadRequest,
186 "Invalid upload path".to_string()
187 ));
188 }
189
190 let base_folder = base_folder
191 .canonicalize()
192 .map_err(|_e| controller_err!(NotFound, "File not found".to_string()))?;
193 let path = base_folder
194 .join(requested_path)
195 .canonicalize()
196 .map_err(|_e| controller_err!(NotFound, "File not found".to_string()))?;
197 if !path.starts_with(&base_folder) {
198 return Err(controller_err!(
199 BadRequest,
200 "Invalid upload path".to_string()
201 ));
202 }
203
204 let named_file = NamedFile::open(path).map_err(|_e| {
205 ControllerError::new(
206 ControllerErrorType::NotFound,
207 "File not found".to_string(),
208 None,
209 )
210 })?;
211 let path = named_file.path();
212 let contents = read(path).await.map_err(|_e| {
213 ControllerError::new(
214 ControllerErrorType::InternalServerError,
215 "Could not read file".to_string(),
216 None,
217 )
218 })?;
219
220 let details = models::file_uploads::get_details_by_path(&mut conn, relative_path)
221 .await
222 .optional()?;
223 let mime_type = details.as_ref().map(|d| d.mime.clone()).or_else(|| {
225 let extension = path.extension()?.to_string_lossy().to_string();
226 let mime_type = match extension.as_str() {
227 "jpg" => "image/jpg",
228 "png" => "image/png",
229 "svg" => "image/svg+xml",
230 "webp" => "image/webp",
231 "gif" => "image/gif",
232 "pdf" => "application/pdf",
233 _ => return None,
234 };
235 Some(mime_type.to_string())
236 });
237 let mut response = HttpResponse::Ok();
238 if let Some(m) = mime_type {
239 response.append_header(("content-type", m));
240 }
241 let stored_name = details.as_ref().map(|details| details.name.as_str());
242 if query.download.is_some() {
243 let file_name = download_file_name(query.name.as_deref(), stored_name, path);
244 response.append_header((
245 "Content-Disposition",
246 attachment_content_disposition(&file_name),
247 ));
248 } else if let Some(stored_name) = stored_name {
249 response.append_header((
250 "Content-Disposition",
251 format!("filename=\"{}\"", stored_name),
252 ));
253 }
254
255 let token = skip_authorize();
257 token.authorized_ok(response.body(contents))
258}
259
260#[instrument(skip(payload, file_store, app_conf, upload_claim))]
268#[utoipa::path(
269 post,
270 path = "/{exercise_service_slug}",
271 operation_id = "uploadFilesFromExerciseService",
272 tag = "files",
273 params(
274 ("exercise_service_slug" = String, Path, description = "Exercise service slug")
275 ),
276 request_body(
277 content = inline(std::collections::HashMap<String, ExerciseUploadBinary>),
278 content_type = "multipart/form-data"
279 ),
280 responses(
281 (status = 200, description = "Uploaded files", body = [file_uploading::ExerciseServiceUploadResultEntry])
282 )
283)]
284
285async fn upload_from_exercise_service(
286 pool: web::Data<PgPool>,
287 exercise_service_slug: web::Path<String>,
288 payload: Multipart,
289 file_store: web::Data<dyn FileStore>,
290 user: Option<AuthUser>,
291 upload_claim: Result<UploadClaim, ControllerError>,
292 app_conf: web::Data<ApplicationConfiguration>,
293) -> ControllerResult<web::Json<Vec<file_uploading::ExerciseServiceUploadResultEntry>>> {
294 let mut conn = pool.acquire().await?;
295 let token = skip_authorize();
298
299 if exercise_service_slug.as_str() != "playground" {
301 match (&upload_claim, &user) {
303 (Ok(upload_claim), _) => {
304 if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
305 return Err(ControllerError::new(
307 ControllerErrorType::BadRequest,
308 "Exercise service slug did not match upload claim".to_string(),
309 None,
310 ));
311 }
312 }
313 (_, Some(_user)) => {
314 }
316 (Err(_), None) => {
317 return Err(ControllerError::new(
318 ControllerErrorType::BadRequest,
319 "Not logged in or missing upload claim".to_string(),
320 None,
321 ));
322 }
323 }
324 }
325
326 let mut cleanup = file_uploading::UploadCleanup::new(file_store.clone());
327 let uploaded_files = match file_uploading::process_exercise_service_upload(
328 &mut conn,
329 exercise_service_slug.as_str(),
330 payload,
331 file_store.as_ref(),
332 &mut cleanup.uploaded_paths,
333 user.map(|user| user.id),
334 &app_conf,
335 )
336 .await
337 {
338 Ok(uploads) => uploads.into_iter().map(|upload| upload.entry).collect(),
339 Err(outer_err) => {
340 cleanup.clean_up().await;
341 return Err(outer_err);
342 }
343 };
344 cleanup.disarm();
345
346 token.authorized_ok(web::Json(uploaded_files))
347}
348
349#[instrument(skip(payload, file_store, app_conf))]
361#[utoipa::path(
362 post,
363 path = "/answer-uploads/{exercise_task_id}",
364 operation_id = "uploadFilesForExerciseAnswer",
365 tag = "files",
366 params(
367 ("exercise_task_id" = Uuid, Path, description = "Exercise task the files are attached to")
368 ),
369 request_body(
370 content = inline(std::collections::HashMap<String, ExerciseUploadBinary>),
371 content_type = "multipart/form-data"
372 ),
373 responses(
374 (status = 200, description = "Uploaded files", body = [file_uploading::ExerciseServiceUploadResultEntry])
375 )
376)]
377async fn upload_answer_files(
378 pool: web::Data<PgPool>,
379 exercise_task_id: web::Path<Uuid>,
380 payload: Multipart,
381 file_store: web::Data<dyn FileStore>,
382 user: AuthUser,
383 app_conf: web::Data<ApplicationConfiguration>,
384) -> ControllerResult<web::Json<Vec<file_uploading::ExerciseServiceUploadResultEntry>>> {
385 let mut conn = pool.acquire().await?;
386 let slide = models::exercise_slides::get_exercise_slide_by_exercise_task_id(
387 &mut conn,
388 *exercise_task_id,
389 )
390 .await?
391 .ok_or_else(|| controller_err!(NotFound, "Exercise task not found".to_string()))?;
392 let token = authorize(
393 &mut conn,
394 Act::View,
395 Some(user.id),
396 Res::ExerciseTask(*exercise_task_id),
397 )
398 .await?;
399 let exercise = models::exercises::get_by_id(&mut conn, slide.exercise_id).await?;
400 domain::exercises::verify_user_can_answer_exercise_slide(
403 &mut conn, user.id, &exercise, slide.id,
404 )
405 .await?;
406
407 let mut cleanup = file_uploading::UploadCleanup::new(file_store.clone());
408 let stored = store_answer_uploads(
409 &mut conn,
410 &file_uploading::AnswerUploadDestination {
411 owner: CourseOrExamId::from_course_and_exam_ids(exercise.course_id, exercise.exam_id)?,
412 exercise_id: slide.exercise_id,
413 user_id: user.id,
414 },
415 payload,
416 file_store.as_ref(),
417 &mut cleanup.uploaded_paths,
418 &app_conf,
419 )
420 .await;
421 let uploads = match stored {
422 Ok(uploads) => uploads,
423 Err(error) => {
424 cleanup.clean_up().await;
425 return Err(error);
426 }
427 };
428 cleanup.disarm();
429
430 let entries = uploads.into_iter().map(|upload| upload.entry).collect();
431 token.authorized_ok(web::Json(entries))
432}
433
434#[derive(Debug, Deserialize)]
436struct DownloadClaimQuery {
437 #[serde(rename = "download-claim")]
438 download_claim: String,
439}
440
441#[instrument(skip(file_store, jwt_key, query))]
449async fn redirect_claimed_file(
450 file_upload_id: web::Path<Uuid>,
451 query: web::Query<DownloadClaimQuery>,
452 pool: web::Data<PgPool>,
453 file_store: web::Data<dyn FileStore>,
454 jwt_key: web::Data<JwtKey>,
455) -> ControllerResult<HttpResponse> {
456 let token = skip_authorize();
458 let claim = DownloadClaim::validate(&query.download_claim, &jwt_key)
459 .map_err(|err| controller_err!(BadRequest, format!("Invalid jwt key: {err}"), err))?;
460 if claim.file_upload_id() != *file_upload_id {
461 return Err(controller_err!(
462 BadRequest,
463 "Download claim does not match the requested file".to_string()
464 ));
465 }
466
467 let mut conn = pool.acquire().await?;
468 let file = models::file_uploads::get_many(&mut conn, &[*file_upload_id])
469 .await?
470 .pop()
471 .ok_or_else(|| controller_err!(NotFound, "File not found".to_string()))?;
472 let url = file_store
473 .get_direct_download_url(Path::new(&file.path))
474 .await
475 .map_err(|err| controller_err!(NotFound, "File not found".to_string(), err))?;
476
477 token.authorized_ok(
478 HttpResponse::Found()
479 .append_header(("location", url))
480 .append_header(("cache-control", "max-age=300, private"))
481 .finish(),
482 )
483}
484
485async fn store_answer_uploads(
492 conn: &mut PgConnection,
493 destination: &file_uploading::AnswerUploadDestination,
494 payload: Multipart,
495 file_store: &dyn FileStore,
496 uploaded_paths: &mut Vec<file_uploading::ExerciseServiceUploadCleanup>,
497 app_conf: &ApplicationConfiguration,
498) -> Result<Vec<file_uploading::ExerciseServiceUpload>, ControllerError> {
499 let streamed = file_uploading::stream_exercise_service_upload(
500 file_uploading::UploadPathScheme::Answer(destination),
501 payload,
502 file_store,
503 uploaded_paths,
504 app_conf,
505 )
506 .await?;
507
508 let mut tx = conn.begin().await?;
509 let uploads = file_uploading::record_exercise_service_upload(
510 &mut tx,
511 streamed,
512 Some(destination.user_id),
513 )
514 .await?;
515 let file_upload_ids: Vec<Uuid> = uploads.iter().map(|upload| upload.entry.id).collect();
516 models::exercise_answer_uploads::insert_many(
517 &mut tx,
518 destination.exercise_id,
519 destination.user_id,
520 &file_upload_ids,
521 models::exercise_answer_uploads::AnswerUploadOrigin::Iframe,
522 )
523 .await?;
524 tx.commit().await?;
525 Ok(uploads)
526}
527
528pub fn _add_routes(cfg: &mut ServiceConfig) {
536 cfg.route("/uploads/{tail:.*}", web::get().to(serve_upload))
537 .route(
538 "/answer-uploads/{exercise_task_id}",
539 web::post().to(upload_answer_files),
540 )
541 .route(
542 "/claimed/{file_upload_id}",
543 web::get().to(redirect_claimed_file),
544 )
545 .route(
546 "/{exercise_service_slug}",
547 web::post().to(upload_from_exercise_service),
548 )
549 .route("{tail:.*}", web::get().to(redirect_to_storage_service));
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn exercise_upload_openapi_body_is_a_string_keyed_binary_map() {
558 let document = serde_json::to_value(FilesApiDoc::openapi()).unwrap();
559 let schema = document
560 .pointer("/paths/~1{exercise_service_slug}/post/requestBody/content/multipart~1form-data/schema")
561 .unwrap();
562
563 assert_eq!(schema["type"], "object");
564 assert_eq!(schema["additionalProperties"]["type"], "string");
565 assert_eq!(schema["additionalProperties"]["format"], "binary");
566 }
567}
568
569#[cfg(test)]
570mod answer_upload_tests {
571 use super::*;
572 use crate::domain::models_requests::JwtKey;
573 use crate::test_helper::*;
574 use actix_session::{SessionMiddleware, storage::CookieSessionStore};
575 use actix_web::cookie::{Cookie, Key, SameSite};
576 use actix_web::http::StatusCode;
577 use actix_web::{App, test};
578 use chrono::{Duration, Utc};
579 use models::exercise_answer_uploads::AnswerUploadBinding;
580 use models::exercise_answer_uploads::AnswerUploadOrigin;
581 use std::sync::Arc;
582
583 const BOUNDARY: &str = "answeruploadboundary";
584 const SESSION_KEY_BYTES: &[u8] =
585 b"answer-upload-tests-cookie-signing-key-that-is-long-enough-abcdef";
586
587 async fn test_login(user_id: web::Path<Uuid>, session: actix_session::Session) -> HttpResponse {
589 let now = Utc::now();
590 crate::domain::authentication::remember(
591 &session,
592 models::users::User {
593 id: *user_id,
594 created_at: now,
595 updated_at: now,
596 deleted_at: None,
597 upstream_id: None,
598 email_domain: None,
599 },
600 )
601 .expect("remember the user");
602 HttpResponse::Ok().finish()
603 }
604
605 macro_rules! files_app {
608 () => {{
609 let pool = PgPool::connect(&dev_database_url()).await.expect("pool");
610 let file_store: Arc<dyn FileStore> = Arc::new(temp_file_store());
611 test::init_service(
612 App::new()
613 .app_data(web::Data::new(pool))
614 .app_data(web::Data::from(file_store))
615 .app_data(web::Data::new(init_app_conf().expect("app conf")))
616 .app_data(web::Data::new(JwtKey::test_key()))
617 .service(
618 web::resource("/test-login/{user_id}").route(web::post().to(test_login)),
619 )
620 .service(web::scope("/api/v0/files").configure(_add_routes))
621 .wrap(
622 SessionMiddleware::builder(
623 CookieSessionStore::default(),
624 Key::from(SESSION_KEY_BYTES),
625 )
626 .cookie_secure(false)
627 .cookie_same_site(SameSite::Lax)
628 .cookie_path("/".to_string())
629 .build(),
630 ),
631 )
632 .await
633 }};
634 }
635
636 macro_rules! login {
637 ($app:expr, $user:expr) => {{
638 let request = test::TestRequest::post()
639 .uri(&format!("/test-login/{}", $user))
640 .to_request();
641 let response = test::call_service(&$app, request).await;
642 assert_eq!(response.status(), StatusCode::OK);
643 response
644 .response()
645 .cookies()
646 .next()
647 .expect("session cookie")
648 .into_owned()
649 }};
650 }
651
652 fn multipart_body(parts: &[(Uuid, &str, &str)]) -> Vec<u8> {
653 let mut body = String::new();
654 for (field_name, file_name, contents) in parts {
655 body.push_str(&format!("--{BOUNDARY}\r\n"));
656 body.push_str(&format!(
657 "Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{file_name}\"\r\n"
658 ));
659 body.push_str("Content-Type: application/octet-stream\r\n\r\n");
660 body.push_str(contents);
661 body.push_str("\r\n");
662 }
663 body.push_str(&format!("--{BOUNDARY}--\r\n"));
664 body.into_bytes()
665 }
666
667 fn upload_request(
668 uri: &str,
669 session: Option<&Cookie<'static>>,
670 parts: &[(Uuid, &str, &str)],
671 ) -> test::TestRequest {
672 let mut request = test::TestRequest::post()
673 .uri(uri)
674 .insert_header((
675 "Content-Type",
676 format!("multipart/form-data; boundary={BOUNDARY}"),
677 ))
678 .set_payload(multipart_body(parts));
679 if let Some(cookie) = session {
680 request = request.cookie(cookie.clone());
681 }
682 request
683 }
684
685 fn answer_upload_uri(exercise_task_id: Uuid) -> String {
686 format!("/api/v0/files/answer-uploads/{exercise_task_id}")
687 }
688
689 #[actix_web::test]
690 async fn answer_uploads_reject_anonymous_callers() {
691 let app = files_app!();
692 let request = upload_request(
693 &answer_upload_uri(Uuid::new_v4()),
694 None,
695 &[(Uuid::new_v4(), "a.txt", "first")],
696 )
697 .to_request();
698
699 let response = test::call_service(&app, request).await;
700
701 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
702 }
703
704 #[actix_web::test]
705 async fn answer_uploads_to_a_missing_exercise_task_are_not_found() {
706 let app = files_app!();
707 let cookie = login!(app, Uuid::new_v4());
708 let request = upload_request(
709 &answer_upload_uri(Uuid::new_v4()),
710 Some(&cookie),
711 &[(Uuid::new_v4(), "a.txt", "first")],
712 )
713 .to_request();
714
715 let response = test::call_service(&app, request).await;
716
717 assert_eq!(response.status(), StatusCode::NOT_FOUND);
718 }
719
720 #[actix_web::test]
723 async fn the_answer_upload_path_is_not_shadowed_by_the_slug_route() {
724 let app = files_app!();
725 let parts = [(Uuid::new_v4(), "a.txt", "first")];
726
727 let bound = test::call_service(
728 &app,
729 upload_request(&answer_upload_uri(Uuid::new_v4()), None, &parts).to_request(),
730 )
731 .await;
732 let by_slug = test::call_service(
733 &app,
734 upload_request("/api/v0/files/example-exercise", None, &parts).to_request(),
735 )
736 .await;
737
738 assert_eq!(bound.status(), StatusCode::UNAUTHORIZED);
740 assert_eq!(by_slug.status(), StatusCode::UNPROCESSABLE_ENTITY);
741 }
742
743 struct CourseTask {
745 user: Uuid,
746 exercise: Uuid,
747 task: Uuid,
748 }
749
750 async fn course_task(enrolled: bool) -> CourseTask {
755 insert_data!(:tx, user: user, :org, :course, instance: instance, :course_module, :chapter, :page, exercise: exercise, :slide, task: task);
756 if enrolled {
757 models::course_instance_enrollments::insert_enrollment_and_set_as_current(
758 tx.as_mut(),
759 models::course_instance_enrollments::NewCourseInstanceEnrollment {
760 course_id: course,
761 course_instance_id: instance.id,
762 user_id: user,
763 },
764 )
765 .await
766 .expect("the enrollment");
767 }
768 tx.commit().await;
769 CourseTask {
770 user,
771 exercise,
772 task,
773 }
774 }
775
776 async fn expire_deadline(exercise: Uuid) {
779 let mut conn = Conn::init().await;
780 let mut tx = conn.begin().await;
781 models::exercises::set_deadline(
782 tx.as_mut(),
783 exercise,
784 Some(Utc::now() - Duration::days(1)),
785 )
786 .await
787 .expect("the deadline update");
788 tx.commit().await;
789 }
790
791 async fn exhaust_tries(exercise: Uuid) {
793 let mut conn = Conn::init().await;
794 let mut tx = conn.begin().await;
795 models::exercises::set_try_limit(tx.as_mut(), exercise, true, Some(0))
796 .await
797 .expect("the try limit update");
798 tx.commit().await;
799 }
800
801 async fn upload_status(fixture: &CourseTask) -> StatusCode {
802 let app = files_app!();
803 let cookie = login!(app, fixture.user);
804 let request = upload_request(
805 &answer_upload_uri(fixture.task),
806 Some(&cookie),
807 &[(Uuid::new_v4(), "a.txt", "first")],
808 )
809 .to_request();
810
811 test::call_service(&app, request).await.status()
812 }
813
814 #[actix_web::test]
815 async fn answer_uploads_from_a_user_who_is_not_enrolled_are_rejected() {
816 let fixture = course_task(false).await;
817
818 assert_eq!(upload_status(&fixture).await, StatusCode::UNAUTHORIZED);
819 }
820
821 #[actix_web::test]
822 async fn answer_uploads_past_the_exercise_deadline_are_rejected() {
823 let fixture = course_task(true).await;
824 expire_deadline(fixture.exercise).await;
825
826 assert_eq!(
827 upload_status(&fixture).await,
828 StatusCode::UNPROCESSABLE_ENTITY
829 );
830 }
831
832 #[actix_web::test]
833 async fn answer_uploads_from_a_user_out_of_tries_are_rejected() {
834 let fixture = course_task(true).await;
835 exhaust_tries(fixture.exercise).await;
836
837 assert_eq!(
838 upload_status(&fixture).await,
839 StatusCode::UNPROCESSABLE_ENTITY
840 );
841 }
842
843 #[actix_web::test]
844 async fn answer_uploads_return_bound_database_ids_in_request_order() {
845 let CourseTask {
846 user,
847 exercise,
848 task,
849 } = course_task(true).await;
850 let app = files_app!();
851 let cookie = login!(app, user);
852 let first_field = Uuid::new_v4();
853 let second_field = Uuid::new_v4();
854 let request = upload_request(
855 &answer_upload_uri(task),
856 Some(&cookie),
857 &[
858 (first_field, "a.tar.zst", "first"),
859 (second_field, "b.txt", "second"),
860 ],
861 )
862 .to_request();
863
864 let response = test::call_service(&app, request).await;
865 assert_eq!(response.status(), StatusCode::OK);
866 let entries: Vec<serde_json::Value> = test::read_body_json(response).await;
867
868 let ids: Vec<Uuid> = entries
869 .iter()
870 .map(|entry| {
871 Uuid::parse_str(entry["id"].as_str().expect("an id string")).expect("a uuid id")
872 })
873 .collect();
874 assert_eq!(ids.len(), 2);
875 assert!(!ids.contains(&first_field) && !ids.contains(&second_field));
876
877 let mut conn = Conn::init().await;
878 let mut check = conn.begin().await;
879 let stored = models::file_uploads::get_many(check.as_mut(), &ids)
880 .await
881 .expect("file uploads");
882 let names: Vec<&str> = ids
883 .iter()
884 .map(|id| {
885 stored
886 .iter()
887 .find(|file| &file.id == id)
888 .map(|file| file.name.as_str())
889 .expect("a file upload row for every returned id")
890 })
891 .collect();
892 assert_eq!(names, vec!["a.tar.zst", "b.txt"]);
893
894 let bindings =
895 models::exercise_answer_uploads::get_by_file_upload_ids(check.as_mut(), &ids)
896 .await
897 .expect("the bindings");
898 assert_eq!(bindings.len(), 2);
899 for AnswerUploadBinding {
900 file_upload_id,
901 exercise_id,
902 user_id,
903 origin,
904 } in bindings
905 {
906 assert!(ids.contains(&file_upload_id));
907 assert_eq!(exercise_id, exercise);
908 assert_eq!(user_id, user);
909 assert_eq!(origin, AnswerUploadOrigin::Iframe);
910 }
911 check.rollback().await;
912 }
913}
914
915#[cfg(test)]
916mod claimed_file_tests {
917 use super::*;
918 use crate::domain::models_requests::DOWNLOAD_CLAIM_PARAM;
919 use crate::test_helper::*;
920 use actix_web::http::StatusCode;
921 use actix_web::{App, test};
922 use std::sync::Arc;
923
924 macro_rules! claimed_files_app {
925 ($file_store:expr) => {{
926 let pool = PgPool::connect(&dev_database_url()).await.expect("pool");
927 test::init_service(
928 App::new()
929 .app_data(web::Data::new(pool))
930 .app_data(web::Data::from($file_store))
931 .app_data(web::Data::new(JwtKey::test_key()))
932 .service(web::scope("/api/v0/files").configure(_add_routes)),
933 )
934 .await
935 }};
936 }
937
938 fn claimed_uri(file_upload_id: Uuid, claim: &str) -> String {
939 format!("/api/v0/files/claimed/{file_upload_id}?{DOWNLOAD_CLAIM_PARAM}={claim}")
940 }
941
942 fn claim_for(file_upload_id: Uuid) -> String {
943 DownloadClaim::expiring_in_1_day(file_upload_id)
944 .sign(&JwtKey::test_key())
945 .expect("signing should succeed")
946 }
947
948 async fn stored_file(store: &Arc<dyn FileStore>) -> (String, Uuid) {
950 let path = format!("claimed-file-tests/{}.txt", Uuid::new_v4());
951 store
952 .upload(Path::new(&path), b"contents".to_vec(), "text/plain")
953 .await
954 .expect("the stored object");
955 let id = insert_file_upload(&path).await;
956 (path, id)
957 }
958
959 async fn insert_file_upload(path: &str) -> Uuid {
961 let mut conn = Conn::init().await;
962 let mut tx = conn.begin().await;
963 let id = models::file_uploads::insert(
964 tx.as_mut(),
965 "answer.txt",
966 path,
967 "text/plain",
968 None,
969 Some(8),
970 )
971 .await
972 .expect("the file upload row");
973 tx.commit().await;
974 id
975 }
976
977 async fn soft_delete_file_upload(id: Uuid) {
978 let mut conn = Conn::init().await;
979 let mut tx = conn.begin().await;
980 models::file_uploads::delete_and_fetch_path(tx.as_mut(), id)
981 .await
982 .expect("the file upload row");
983 tx.commit().await;
984 }
985
986 #[actix_web::test]
987 async fn a_claimed_file_redirects_to_the_store_url() {
988 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
989 let (path, id) = stored_file(&store).await;
990 let expected_url = store
991 .get_direct_download_url(Path::new(&path))
992 .await
993 .expect("the store url");
994 let app = claimed_files_app!(Arc::clone(&store));
995
996 let response = test::call_service(
997 &app,
998 test::TestRequest::get()
999 .uri(&claimed_uri(id, &claim_for(id)))
1000 .to_request(),
1001 )
1002 .await;
1003
1004 assert_eq!(response.status(), StatusCode::FOUND);
1005 assert_eq!(
1006 response
1007 .headers()
1008 .get("location")
1009 .expect("a location header"),
1010 expected_url.as_str()
1011 );
1012 }
1013
1014 #[actix_web::test]
1017 async fn a_claim_for_another_file_is_rejected() {
1018 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1019 let (_path, id) = stored_file(&store).await;
1020 let app = claimed_files_app!(Arc::clone(&store));
1021
1022 let response = test::call_service(
1023 &app,
1024 test::TestRequest::get()
1025 .uri(&claimed_uri(id, &claim_for(Uuid::new_v4())))
1026 .to_request(),
1027 )
1028 .await;
1029
1030 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1031 }
1032
1033 #[actix_web::test]
1034 async fn a_tampered_claim_is_rejected() {
1035 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1036 let app = claimed_files_app!(Arc::clone(&store));
1037 let id = Uuid::new_v4();
1038 let mut claim = claim_for(id);
1039 claim.pop();
1040
1041 let response = test::call_service(
1042 &app,
1043 test::TestRequest::get()
1044 .uri(&claimed_uri(id, &claim))
1045 .to_request(),
1046 )
1047 .await;
1048
1049 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1050 }
1051
1052 #[actix_web::test]
1053 async fn a_request_without_a_claim_is_rejected() {
1054 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1055 let app = claimed_files_app!(Arc::clone(&store));
1056
1057 let response = test::call_service(
1058 &app,
1059 test::TestRequest::get()
1060 .uri(&format!("/api/v0/files/claimed/{}", Uuid::new_v4()))
1061 .to_request(),
1062 )
1063 .await;
1064
1065 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1066 }
1067
1068 #[actix_web::test]
1069 async fn a_soft_deleted_file_is_not_found() {
1070 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1071 let (_path, id) = stored_file(&store).await;
1072 soft_delete_file_upload(id).await;
1073 let app = claimed_files_app!(Arc::clone(&store));
1074
1075 let response = test::call_service(
1076 &app,
1077 test::TestRequest::get()
1078 .uri(&claimed_uri(id, &claim_for(id)))
1079 .to_request(),
1080 )
1081 .await;
1082
1083 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1084 }
1085
1086 #[actix_web::test]
1089 async fn the_claimed_file_path_is_not_shadowed_by_the_catch_all() {
1090 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1091 let path = format!("claimed/{}", Uuid::new_v4());
1092 store
1093 .upload(Path::new(&path), b"contents".to_vec(), "text/plain")
1094 .await
1095 .expect("the stored object");
1096 let app = claimed_files_app!(Arc::clone(&store));
1097
1098 let response = test::call_service(
1099 &app,
1100 test::TestRequest::get()
1101 .uri(&format!("/api/v0/files/{path}"))
1102 .to_request(),
1103 )
1104 .await;
1105
1106 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1109 }
1110}