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