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.base_url,
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 exercise_task =
315 models::exercise_tasks::get_exercise_task_by_id(&mut conn, *exercise_task_id).await?;
316 let token = authorize(
317 &mut conn,
318 Act::View,
319 Some(user.id),
320 Res::ExerciseTask(*exercise_task_id),
321 )
322 .await?;
323 let exercise = models::exercises::get_by_id(&mut conn, slide.exercise_id).await?;
324 domain::exercises::verify_user_can_answer_exercise_slide(
327 &mut conn, user.id, &exercise, slide.id,
328 )
329 .await?;
330
331 let mut cleanup = file_uploading::UploadCleanup::new(file_store.clone());
332 let stored = store_answer_uploads(
333 &mut conn,
334 AnswerUploadDestination {
335 exercise_id: slide.exercise_id,
336 user_id: user.id,
337 path_prefix: exercise_task.exercise_type,
338 },
339 payload,
340 file_store.as_ref(),
341 &mut cleanup.uploaded_paths,
342 &app_conf.base_url,
343 )
344 .await;
345 let uploads = match stored {
346 Ok(uploads) => uploads,
347 Err(error) => {
348 cleanup.clean_up().await;
349 return Err(error);
350 }
351 };
352 cleanup.disarm();
353
354 let entries = uploads.into_iter().map(|upload| upload.entry).collect();
355 token.authorized_ok(web::Json(entries))
356}
357
358#[derive(Debug, Deserialize)]
360struct DownloadClaimQuery {
361 #[serde(rename = "download-claim")]
362 download_claim: String,
363}
364
365#[instrument(skip(file_store, jwt_key, query))]
373async fn redirect_claimed_file(
374 file_upload_id: web::Path<Uuid>,
375 query: web::Query<DownloadClaimQuery>,
376 pool: web::Data<PgPool>,
377 file_store: web::Data<dyn FileStore>,
378 jwt_key: web::Data<JwtKey>,
379) -> ControllerResult<HttpResponse> {
380 let token = skip_authorize();
382 let claim = DownloadClaim::validate(&query.download_claim, &jwt_key)?;
383 if claim.file_upload_id() != *file_upload_id {
384 return Err(controller_err!(
385 BadRequest,
386 "Download claim does not match the requested file".to_string()
387 ));
388 }
389
390 let mut conn = pool.acquire().await?;
391 let file = models::file_uploads::get_many(&mut conn, &[*file_upload_id])
392 .await?
393 .pop()
394 .ok_or_else(|| controller_err!(NotFound, "File not found".to_string()))?;
395 let url = file_store
396 .get_direct_download_url(Path::new(&file.path))
397 .await
398 .map_err(|err| controller_err!(NotFound, "File not found".to_string(), err))?;
399
400 token.authorized_ok(
401 HttpResponse::Found()
402 .append_header(("location", url))
403 .append_header(("cache-control", "max-age=300, private"))
404 .finish(),
405 )
406}
407
408struct AnswerUploadDestination {
410 exercise_id: Uuid,
411 user_id: Uuid,
412 path_prefix: String,
415}
416
417async fn store_answer_uploads(
424 conn: &mut PgConnection,
425 destination: AnswerUploadDestination,
426 payload: Multipart,
427 file_store: &dyn FileStore,
428 uploaded_paths: &mut Vec<file_uploading::ExerciseServiceUploadCleanup>,
429 base_url: &str,
430) -> Result<Vec<file_uploading::ExerciseServiceUpload>, ControllerError> {
431 let streamed = file_uploading::stream_exercise_service_upload(
432 &destination.path_prefix,
433 payload,
434 file_store,
435 uploaded_paths,
436 base_url,
437 )
438 .await?;
439
440 let mut tx = conn.begin().await?;
441 let uploads = file_uploading::record_exercise_service_upload(
442 &mut tx,
443 streamed,
444 Some(destination.user_id),
445 )
446 .await?;
447 let file_upload_ids: Vec<Uuid> = uploads.iter().map(|upload| upload.entry.id).collect();
448 models::exercise_answer_uploads::insert_many(
449 &mut tx,
450 destination.exercise_id,
451 destination.user_id,
452 &file_upload_ids,
453 models::exercise_answer_uploads::AnswerUploadOrigin::Iframe,
454 )
455 .await?;
456 tx.commit().await?;
457 Ok(uploads)
458}
459
460pub fn _add_routes(cfg: &mut ServiceConfig) {
468 cfg.route("/uploads/{tail:.*}", web::get().to(serve_upload))
469 .route(
470 "/answer-uploads/{exercise_task_id}",
471 web::post().to(upload_answer_files),
472 )
473 .route(
474 "/claimed/{file_upload_id}",
475 web::get().to(redirect_claimed_file),
476 )
477 .route(
478 "/{exercise_service_slug}",
479 web::post().to(upload_from_exercise_service),
480 )
481 .route("{tail:.*}", web::get().to(redirect_to_storage_service));
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn exercise_upload_openapi_body_is_a_string_keyed_binary_map() {
490 let document = serde_json::to_value(FilesApiDoc::openapi()).unwrap();
491 let schema = document
492 .pointer("/paths/~1{exercise_service_slug}/post/requestBody/content/multipart~1form-data/schema")
493 .unwrap();
494
495 assert_eq!(schema["type"], "object");
496 assert_eq!(schema["additionalProperties"]["type"], "string");
497 assert_eq!(schema["additionalProperties"]["format"], "binary");
498 }
499}
500
501#[cfg(test)]
502mod answer_upload_tests {
503 use super::*;
504 use crate::domain::models_requests::JwtKey;
505 use crate::test_helper::*;
506 use actix_session::{SessionMiddleware, storage::CookieSessionStore};
507 use actix_web::cookie::{Cookie, Key, SameSite};
508 use actix_web::http::StatusCode;
509 use actix_web::{App, test};
510 use chrono::{Duration, Utc};
511 use models::exercise_answer_uploads::AnswerUploadBinding;
512 use models::exercise_answer_uploads::AnswerUploadOrigin;
513 use std::sync::Arc;
514
515 const BOUNDARY: &str = "answeruploadboundary";
516 const SESSION_KEY_BYTES: &[u8] =
517 b"answer-upload-tests-cookie-signing-key-that-is-long-enough-abcdef";
518
519 async fn test_login(user_id: web::Path<Uuid>, session: actix_session::Session) -> HttpResponse {
521 let now = Utc::now();
522 crate::domain::authentication::remember(
523 &session,
524 models::users::User {
525 id: *user_id,
526 created_at: now,
527 updated_at: now,
528 deleted_at: None,
529 upstream_id: None,
530 email_domain: None,
531 },
532 )
533 .expect("remember the user");
534 HttpResponse::Ok().finish()
535 }
536
537 macro_rules! files_app {
540 () => {{
541 let pool = PgPool::connect(&test_database_url()).await.expect("pool");
542 let file_store: Arc<dyn FileStore> = Arc::new(temp_file_store());
543 test::init_service(
544 App::new()
545 .app_data(web::Data::new(pool))
546 .app_data(web::Data::from(file_store))
547 .app_data(web::Data::new(init_app_conf().expect("app conf")))
548 .app_data(web::Data::new(JwtKey::test_key()))
549 .service(
550 web::resource("/test-login/{user_id}").route(web::post().to(test_login)),
551 )
552 .service(web::scope("/api/v0/files").configure(_add_routes))
553 .wrap(
554 SessionMiddleware::builder(
555 CookieSessionStore::default(),
556 Key::from(SESSION_KEY_BYTES),
557 )
558 .cookie_secure(false)
559 .cookie_same_site(SameSite::Lax)
560 .cookie_path("/".to_string())
561 .build(),
562 ),
563 )
564 .await
565 }};
566 }
567
568 macro_rules! login {
569 ($app:expr, $user:expr) => {{
570 let request = test::TestRequest::post()
571 .uri(&format!("/test-login/{}", $user))
572 .to_request();
573 let response = test::call_service(&$app, request).await;
574 assert_eq!(response.status(), StatusCode::OK);
575 response
576 .response()
577 .cookies()
578 .next()
579 .expect("session cookie")
580 .into_owned()
581 }};
582 }
583
584 fn multipart_body(parts: &[(Uuid, &str, &str)]) -> Vec<u8> {
585 let mut body = String::new();
586 for (field_name, file_name, contents) in parts {
587 body.push_str(&format!("--{BOUNDARY}\r\n"));
588 body.push_str(&format!(
589 "Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{file_name}\"\r\n"
590 ));
591 body.push_str("Content-Type: application/octet-stream\r\n\r\n");
592 body.push_str(contents);
593 body.push_str("\r\n");
594 }
595 body.push_str(&format!("--{BOUNDARY}--\r\n"));
596 body.into_bytes()
597 }
598
599 fn upload_request(
600 uri: &str,
601 session: Option<&Cookie<'static>>,
602 parts: &[(Uuid, &str, &str)],
603 ) -> test::TestRequest {
604 let mut request = test::TestRequest::post()
605 .uri(uri)
606 .insert_header((
607 "Content-Type",
608 format!("multipart/form-data; boundary={BOUNDARY}"),
609 ))
610 .set_payload(multipart_body(parts));
611 if let Some(cookie) = session {
612 request = request.cookie(cookie.clone());
613 }
614 request
615 }
616
617 fn answer_upload_uri(exercise_task_id: Uuid) -> String {
618 format!("/api/v0/files/answer-uploads/{exercise_task_id}")
619 }
620
621 #[actix_web::test]
622 async fn answer_uploads_reject_anonymous_callers() {
623 let app = files_app!();
624 let request = upload_request(
625 &answer_upload_uri(Uuid::new_v4()),
626 None,
627 &[(Uuid::new_v4(), "a.txt", "first")],
628 )
629 .to_request();
630
631 let response = test::call_service(&app, request).await;
632
633 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
634 }
635
636 #[actix_web::test]
637 async fn answer_uploads_to_a_missing_exercise_task_are_not_found() {
638 let app = files_app!();
639 let cookie = login!(app, Uuid::new_v4());
640 let request = upload_request(
641 &answer_upload_uri(Uuid::new_v4()),
642 Some(&cookie),
643 &[(Uuid::new_v4(), "a.txt", "first")],
644 )
645 .to_request();
646
647 let response = test::call_service(&app, request).await;
648
649 assert_eq!(response.status(), StatusCode::NOT_FOUND);
650 }
651
652 #[actix_web::test]
655 async fn the_answer_upload_path_is_not_shadowed_by_the_slug_route() {
656 let app = files_app!();
657 let parts = [(Uuid::new_v4(), "a.txt", "first")];
658
659 let bound = test::call_service(
660 &app,
661 upload_request(&answer_upload_uri(Uuid::new_v4()), None, &parts).to_request(),
662 )
663 .await;
664 let by_slug = test::call_service(
665 &app,
666 upload_request("/api/v0/files/example-exercise", None, &parts).to_request(),
667 )
668 .await;
669
670 assert_eq!(bound.status(), StatusCode::UNAUTHORIZED);
672 assert_eq!(by_slug.status(), StatusCode::UNPROCESSABLE_ENTITY);
673 }
674
675 struct CourseTask {
677 user: Uuid,
678 exercise: Uuid,
679 task: Uuid,
680 }
681
682 async fn course_task(enrolled: bool) -> CourseTask {
687 insert_data!(:tx, user: user, :org, :course, instance: instance, :course_module, :chapter, :page, exercise: exercise, :slide, task: task);
688 if enrolled {
689 models::course_instance_enrollments::insert_enrollment_and_set_as_current(
690 tx.as_mut(),
691 models::course_instance_enrollments::NewCourseInstanceEnrollment {
692 course_id: course,
693 course_instance_id: instance.id,
694 user_id: user,
695 },
696 )
697 .await
698 .expect("the enrollment");
699 }
700 tx.commit().await;
701 CourseTask {
702 user,
703 exercise,
704 task,
705 }
706 }
707
708 async fn expire_deadline(exercise: Uuid) {
711 let mut conn = Conn::init().await;
712 let mut tx = conn.begin().await;
713 models::exercises::set_deadline(
714 tx.as_mut(),
715 exercise,
716 Some(Utc::now() - Duration::days(1)),
717 )
718 .await
719 .expect("the deadline update");
720 tx.commit().await;
721 }
722
723 async fn exhaust_tries(exercise: Uuid) {
725 let mut conn = Conn::init().await;
726 let mut tx = conn.begin().await;
727 models::exercises::set_try_limit(tx.as_mut(), exercise, true, Some(0))
728 .await
729 .expect("the try limit update");
730 tx.commit().await;
731 }
732
733 async fn upload_status(fixture: &CourseTask) -> StatusCode {
734 let app = files_app!();
735 let cookie = login!(app, fixture.user);
736 let request = upload_request(
737 &answer_upload_uri(fixture.task),
738 Some(&cookie),
739 &[(Uuid::new_v4(), "a.txt", "first")],
740 )
741 .to_request();
742
743 test::call_service(&app, request).await.status()
744 }
745
746 #[actix_web::test]
747 async fn answer_uploads_from_a_user_who_is_not_enrolled_are_rejected() {
748 let fixture = course_task(false).await;
749
750 assert_eq!(upload_status(&fixture).await, StatusCode::UNAUTHORIZED);
751 }
752
753 #[actix_web::test]
754 async fn answer_uploads_past_the_exercise_deadline_are_rejected() {
755 let fixture = course_task(true).await;
756 expire_deadline(fixture.exercise).await;
757
758 assert_eq!(
759 upload_status(&fixture).await,
760 StatusCode::UNPROCESSABLE_ENTITY
761 );
762 }
763
764 #[actix_web::test]
765 async fn answer_uploads_from_a_user_out_of_tries_are_rejected() {
766 let fixture = course_task(true).await;
767 exhaust_tries(fixture.exercise).await;
768
769 assert_eq!(
770 upload_status(&fixture).await,
771 StatusCode::UNPROCESSABLE_ENTITY
772 );
773 }
774
775 #[actix_web::test]
776 async fn answer_uploads_return_bound_database_ids_in_request_order() {
777 let CourseTask {
778 user,
779 exercise,
780 task,
781 } = course_task(true).await;
782 let app = files_app!();
783 let cookie = login!(app, user);
784 let first_field = Uuid::new_v4();
785 let second_field = Uuid::new_v4();
786 let request = upload_request(
787 &answer_upload_uri(task),
788 Some(&cookie),
789 &[
790 (first_field, "a.tar.zst", "first"),
791 (second_field, "b.txt", "second"),
792 ],
793 )
794 .to_request();
795
796 let response = test::call_service(&app, request).await;
797 assert_eq!(response.status(), StatusCode::OK);
798 let entries: Vec<serde_json::Value> = test::read_body_json(response).await;
799
800 let ids: Vec<Uuid> = entries
801 .iter()
802 .map(|entry| {
803 Uuid::parse_str(entry["id"].as_str().expect("an id string")).expect("a uuid id")
804 })
805 .collect();
806 assert_eq!(ids.len(), 2);
807 assert!(!ids.contains(&first_field) && !ids.contains(&second_field));
808
809 let mut conn = Conn::init().await;
810 let mut check = conn.begin().await;
811 let stored = models::file_uploads::get_many(check.as_mut(), &ids)
812 .await
813 .expect("file uploads");
814 let names: Vec<&str> = ids
815 .iter()
816 .map(|id| {
817 stored
818 .iter()
819 .find(|file| &file.id == id)
820 .map(|file| file.name.as_str())
821 .expect("a file upload row for every returned id")
822 })
823 .collect();
824 assert_eq!(names, vec!["a.tar.zst", "b.txt"]);
825
826 let bindings =
827 models::exercise_answer_uploads::get_by_file_upload_ids(check.as_mut(), &ids)
828 .await
829 .expect("the bindings");
830 assert_eq!(bindings.len(), 2);
831 for AnswerUploadBinding {
832 file_upload_id,
833 exercise_id,
834 user_id,
835 origin,
836 } in bindings
837 {
838 assert!(ids.contains(&file_upload_id));
839 assert_eq!(exercise_id, exercise);
840 assert_eq!(user_id, user);
841 assert_eq!(origin, AnswerUploadOrigin::Iframe);
842 }
843 check.rollback().await;
844 }
845}
846
847#[cfg(test)]
848mod claimed_file_tests {
849 use super::*;
850 use crate::domain::models_requests::DOWNLOAD_CLAIM_PARAM;
851 use crate::test_helper::*;
852 use actix_web::http::StatusCode;
853 use actix_web::{App, test};
854 use std::sync::Arc;
855
856 macro_rules! claimed_files_app {
857 ($file_store:expr) => {{
858 let pool = PgPool::connect(&test_database_url()).await.expect("pool");
859 test::init_service(
860 App::new()
861 .app_data(web::Data::new(pool))
862 .app_data(web::Data::from($file_store))
863 .app_data(web::Data::new(JwtKey::test_key()))
864 .service(web::scope("/api/v0/files").configure(_add_routes)),
865 )
866 .await
867 }};
868 }
869
870 fn claimed_uri(file_upload_id: Uuid, claim: &str) -> String {
871 format!("/api/v0/files/claimed/{file_upload_id}?{DOWNLOAD_CLAIM_PARAM}={claim}")
872 }
873
874 fn claim_for(file_upload_id: Uuid) -> String {
875 DownloadClaim::expiring_in_1_day(file_upload_id)
876 .sign(&JwtKey::test_key())
877 .expect("signing should succeed")
878 }
879
880 async fn stored_file(store: &Arc<dyn FileStore>) -> (String, Uuid) {
882 let path = format!("claimed-file-tests/{}.txt", Uuid::new_v4());
883 store
884 .upload(Path::new(&path), b"contents".to_vec(), "text/plain")
885 .await
886 .expect("the stored object");
887 let id = insert_file_upload(&path).await;
888 (path, id)
889 }
890
891 async fn insert_file_upload(path: &str) -> Uuid {
893 let mut conn = Conn::init().await;
894 let mut tx = conn.begin().await;
895 let id = models::file_uploads::insert(
896 tx.as_mut(),
897 "answer.txt",
898 path,
899 "text/plain",
900 None,
901 Some(8),
902 )
903 .await
904 .expect("the file upload row");
905 tx.commit().await;
906 id
907 }
908
909 async fn soft_delete_file_upload(id: Uuid) {
910 let mut conn = Conn::init().await;
911 let mut tx = conn.begin().await;
912 models::file_uploads::delete_and_fetch_path(tx.as_mut(), id)
913 .await
914 .expect("the file upload row");
915 tx.commit().await;
916 }
917
918 #[actix_web::test]
919 async fn a_claimed_file_redirects_to_the_store_url() {
920 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
921 let (path, id) = stored_file(&store).await;
922 let expected_url = store
923 .get_direct_download_url(Path::new(&path))
924 .await
925 .expect("the store url");
926 let app = claimed_files_app!(Arc::clone(&store));
927
928 let response = test::call_service(
929 &app,
930 test::TestRequest::get()
931 .uri(&claimed_uri(id, &claim_for(id)))
932 .to_request(),
933 )
934 .await;
935
936 assert_eq!(response.status(), StatusCode::FOUND);
937 assert_eq!(
938 response
939 .headers()
940 .get("location")
941 .expect("a location header"),
942 expected_url.as_str()
943 );
944 }
945
946 #[actix_web::test]
949 async fn a_claim_for_another_file_is_rejected() {
950 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
951 let (_path, id) = stored_file(&store).await;
952 let app = claimed_files_app!(Arc::clone(&store));
953
954 let response = test::call_service(
955 &app,
956 test::TestRequest::get()
957 .uri(&claimed_uri(id, &claim_for(Uuid::new_v4())))
958 .to_request(),
959 )
960 .await;
961
962 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
963 }
964
965 #[actix_web::test]
966 async fn a_tampered_claim_is_rejected() {
967 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
968 let app = claimed_files_app!(Arc::clone(&store));
969 let id = Uuid::new_v4();
970 let mut claim = claim_for(id);
971 claim.pop();
972
973 let response = test::call_service(
974 &app,
975 test::TestRequest::get()
976 .uri(&claimed_uri(id, &claim))
977 .to_request(),
978 )
979 .await;
980
981 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
982 }
983
984 #[actix_web::test]
985 async fn a_request_without_a_claim_is_rejected() {
986 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
987 let app = claimed_files_app!(Arc::clone(&store));
988
989 let response = test::call_service(
990 &app,
991 test::TestRequest::get()
992 .uri(&format!("/api/v0/files/claimed/{}", Uuid::new_v4()))
993 .to_request(),
994 )
995 .await;
996
997 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
998 }
999
1000 #[actix_web::test]
1001 async fn a_soft_deleted_file_is_not_found() {
1002 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1003 let (_path, id) = stored_file(&store).await;
1004 soft_delete_file_upload(id).await;
1005 let app = claimed_files_app!(Arc::clone(&store));
1006
1007 let response = test::call_service(
1008 &app,
1009 test::TestRequest::get()
1010 .uri(&claimed_uri(id, &claim_for(id)))
1011 .to_request(),
1012 )
1013 .await;
1014
1015 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1016 }
1017
1018 #[actix_web::test]
1021 async fn the_claimed_file_path_is_not_shadowed_by_the_catch_all() {
1022 let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1023 let path = format!("claimed/{}", Uuid::new_v4());
1024 store
1025 .upload(Path::new(&path), b"contents".to_vec(), "text/plain")
1026 .await
1027 .expect("the stored object");
1028 let app = claimed_files_app!(Arc::clone(&store));
1029
1030 let response = test::call_service(
1031 &app,
1032 test::TestRequest::get()
1033 .uri(&format!("/api/v0/files/{path}"))
1034 .to_request(),
1035 )
1036 .await;
1037
1038 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1041 }
1042}