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.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/**
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 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    // `Act::View` on an exercise task falls through to the organization check, which grants every
325    // logged in user, so the right to answer this particular task has to be established here.
326    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/// The download claim as it rides in the URL the host puts in a grading request.
359#[derive(Debug, Deserialize)]
360struct DownloadClaimQuery {
361    #[serde(rename = "download-claim")]
362    download_claim: String,
363}
364
365/**
366GET `/api/v0/files/claimed/:file_upload_id?download-claim=:jwt`
367Redirects to one host-stored file, authorized by a claim naming that file.
368
369Used by exercise services grading a file-typed answer: the claim, not a session, is the
370authorization, and it names a single file so a service cannot reach any other one.
371*/
372#[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    // accessed from exercise services, which cannot authenticate using login
381    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
408/// Who an answer upload is bound to, and where its objects are stored.
409struct AnswerUploadDestination {
410    exercise_id: Uuid,
411    user_id: Uuid,
412    /// The task's exercise-service slug, so the stored objects stay laid out the way the slug
413    /// route lays them out.
414    path_prefix: String,
415}
416
417/// Stores the multipart parts and binds them to the exercise and user, so that a failure to record
418/// the binding cannot leave uploads the reaper is unable to find.
419///
420/// The transaction opens only after the last byte has been streamed: the multipart body has no time
421/// limit, so opening it first would pin a pool connection `idle in transaction` for the whole
422/// upload.
423async 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
460/**
461Add a route for each controller in this module.
462
463The name starts with an underline in order to appear before other functions in the module documentation.
464
465We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
466*/
467pub 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    /// Puts a user into the session without going through a login flow, so `AuthUser` resolves.
520    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    /// The routes under a real actix app, mounted where they are mounted in production so the path
538    /// the tests send is the path a browser sends.
539    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    /// A literal first segment must reach this handler rather than the slug route's wildcard, which
653    /// would silently accept the upload without binding it to anyone.
654    #[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        // The slug route rejects an anonymous caller as unprocessable, this one as unauthorized.
671        assert_eq!(bound.status(), StatusCode::UNAUTHORIZED);
672        assert_eq!(by_slug.status(), StatusCode::UNPROCESSABLE_ENTITY);
673    }
674
675    /// The ids of a committed course fixture the answer-upload route can be pointed at.
676    struct CourseTask {
677        user: Uuid,
678        exercise: Uuid,
679        task: Uuid,
680    }
681
682    /// Builds a course with one exercise task, optionally enrolling the user on it.
683    ///
684    /// Committed because the handler runs on a pool connection of its own and cannot see fixtures
685    /// left uncommitted.
686    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    /// Commits a passed deadline onto the fixture's exercise, which is an exercise setting rather
709    /// than user state and so has to be visible to the request's own connection.
710    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    /// Commits a try limit of zero, so every slide of the fixture's exercise is already exhausted.
724    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    /// A stored object with a `file_uploads` row pointing at it.
881    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    /// Records a stored object, committing so the handler's own connection can see it.
892    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    /// Naming a single file is what keeps a service from reaching any other one, so a claim must
947    /// not authorize the file the path names.
948    #[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    /// A literal first segment must reach this handler rather than the catch-all, which serves any
1019    /// path with no authorization at all.
1020    #[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        // Reaching the catch-all would redirect to the object that is really there; this handler
1039        // instead refuses a request carrying no claim.
1040        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1041    }
1042}