Skip to main content

headless_lms_server/controllers/
files.rs

1/*!
2Handlers for HTTP requests to `/api/v0/files`.
3
4*/
5use 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
14/// OpenAPI-only representation of an arbitrary multipart binary part.
15struct 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/// The upload routes the CMS may call. Only the unbound slug route belongs here: files a teacher
35/// attaches in the exercise editor are referenced from a spec, which never produces an
36/// `exercise_task_submission_files` row, so an answer-upload binding would have them reaped.
37#[derive(OpenApi)]
38#[openapi(paths(upload_from_exercise_service))]
39pub(crate) struct CmsFilesApiDoc;
40/**
41
42GET `/api/v0/files/\*` Redirects the request to a file storage service.
43
44This is meant for redirecting requests to appropriate storage services.
45This approach decouples the storage mechanism from the urls.
46Redirection is done with HTTP status 302 Found and it has a max
47age of 5 minutes.
48
49Redirects to local file handler in development and to a service in production.
50
51
52# Example
53
54`GET /api/v0/files/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg`
55
56Response headers:
57```text
58< HTTP/1.1 302 Found
59< Date: Mon, 26 Apr 2021 10:38:09 GMT
60< Content-Length: 0
61< Connection: keep-alive
62< cache-control: max-age=300, private
63< location: /api/v0/files/uploads/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg
64```
65
66*/
67#[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/**
91GET `/api/v0/files/uploads/\*`
92Serve local uploaded file, mostly for development.
93
94# Example
95
96`GET /api/v0/files/uploads/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg`
97
98Result:
99
100The file.
101*/
102#[instrument(skip(req))]
103async fn serve_upload(req: HttpRequest, pool: web::Data<PgPool>) -> ControllerResult<HttpResponse> {
104    let mut conn = pool.acquire().await?;
105
106    // TODO: replace this whole function with the actix_files::Files service once it works with the used actix version.
107    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    // this endpoint is only used for development
178    let token = skip_authorize();
179    token.authorized_ok(response.body(contents))
180}
181
182/**
183POST `/api/v0/files/:exercise_service_slug`
184Used to upload data from exercise service iframes.
185
186# Returns
187An ordered list of host-assigned file ids and stored URLs.
188*/
189#[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    // accessed from exercise services, can't authenticate using login,
218    // the upload claim is used to verify requests instead
219    let token = skip_authorize();
220
221    // the playground uses the special "playground" slug to upload temporary files
222    if exercise_service_slug.as_str() != "playground" {
223        // non-playground uploads require a valid upload claim or user
224        match (&upload_claim, &user) {
225            (Ok(upload_claim), _) => {
226                if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
227                    // upload claim's exercise type doesn't match the upload url
228                    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                // TODO: for now, all users are allowed to upload files
237            }
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/**
272POST `/api/v0/files/answer-uploads/:exercise_task_id`
273Used to upload the files a student is attaching to an answer for the given exercise task.
274
275Unlike `POST /api/v0/files/:exercise_service_slug` this binds every stored file to the uploader and
276the task's exercise, which is what lets a later submission verify that the answer only names files
277the submitter uploaded for that exercise.
278
279# Returns
280An ordered list of `file_uploads` ids and stored URLs, in the order the parts were sent.
281*/
282#[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    // `Act::View` on an exercise task falls through to the organization check, which grants every
323    // logged in user, so the right to answer this particular task has to be established here.
324    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/// The download claim as it rides in the URL the host puts in a grading request.
357#[derive(Debug, Deserialize)]
358struct DownloadClaimQuery {
359    #[serde(rename = "download-claim")]
360    download_claim: String,
361}
362
363/**
364GET `/api/v0/files/claimed/:file_upload_id?download-claim=:jwt`
365Redirects to one host-stored file, authorized by a claim naming that file.
366
367Used by exercise services grading a file-typed answer: the claim, not a session, is the
368authorization, and it names a single file so a service cannot reach any other one.
369*/
370#[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    // accessed from exercise services, which cannot authenticate using login
379    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
407/// Stores the multipart parts and binds them to the exercise and user, so that a failure to record
408/// the binding cannot leave uploads the reaper is unable to find.
409///
410/// The transaction opens only after the last byte has been streamed: the multipart body has no time
411/// limit, so opening it first would pin a pool connection `idle in transaction` for the whole
412/// upload.
413async 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
450/**
451Add a route for each controller in this module.
452
453The name starts with an underline in order to appear before other functions in the module documentation.
454
455We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
456*/
457pub 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    /// Puts a user into the session without going through a login flow, so `AuthUser` resolves.
510    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    /// The routes under a real actix app, mounted where they are mounted in production so the path
528    /// the tests send is the path a browser sends.
529    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    /// A literal first segment must reach this handler rather than the slug route's wildcard, which
643    /// would silently accept the upload without binding it to anyone.
644    #[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        // The slug route rejects an anonymous caller as unprocessable, this one as unauthorized.
661        assert_eq!(bound.status(), StatusCode::UNAUTHORIZED);
662        assert_eq!(by_slug.status(), StatusCode::UNPROCESSABLE_ENTITY);
663    }
664
665    /// The ids of a committed course fixture the answer-upload route can be pointed at.
666    struct CourseTask {
667        user: Uuid,
668        exercise: Uuid,
669        task: Uuid,
670    }
671
672    /// Builds a course with one exercise task, optionally enrolling the user on it.
673    ///
674    /// Committed because the handler runs on a pool connection of its own and cannot see fixtures
675    /// left uncommitted.
676    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    /// Commits a passed deadline onto the fixture's exercise, which is an exercise setting rather
699    /// than user state and so has to be visible to the request's own connection.
700    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    /// Commits a try limit of zero, so every slide of the fixture's exercise is already exhausted.
714    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    /// A stored object with a `file_uploads` row pointing at it.
871    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    /// Records a stored object, committing so the handler's own connection can see it.
882    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    /// Naming a single file is what keeps a service from reaching any other one, so a claim must
937    /// not authorize the file the path names.
938    #[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    /// A literal first segment must reach this handler rather than the catch-all, which serves any
1009    /// path with no authorization at all.
1010    #[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        // Reaching the catch-all would redirect to the object that is really there; this handler
1029        // instead refuses a request carrying no claim.
1030        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1031    }
1032}