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 headless_lms_utils::file_store::attachment_content_disposition;
11use std::path::{Component, Path};
12use tokio::fs::read;
13use utoipa::{OpenApi, PartialSchema, ToSchema};
14
15/// OpenAPI-only representation of an arbitrary multipart binary part.
16struct ExerciseUploadBinary;
17
18impl PartialSchema for ExerciseUploadBinary {
19    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
20        utoipa::openapi::schema::ObjectBuilder::new()
21            .schema_type(utoipa::openapi::schema::Type::String)
22            .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
23                utoipa::openapi::KnownFormat::Binary,
24            )))
25            .into()
26    }
27}
28
29impl ToSchema for ExerciseUploadBinary {}
30
31#[derive(OpenApi)]
32#[openapi(paths(upload_from_exercise_service, upload_answer_files))]
33pub(crate) struct FilesApiDoc;
34
35/// The upload routes the CMS may call. Only the unbound slug route belongs here: files a teacher
36/// attaches in the exercise editor are referenced from a spec, which never produces an
37/// `exercise_task_submission_files` row, so an answer-upload binding would have them reaped.
38#[derive(OpenApi)]
39#[openapi(paths(upload_from_exercise_service))]
40pub(crate) struct CmsFilesApiDoc;
41#[derive(Debug, Deserialize)]
42struct FileQuery {
43    /// Present when the browser should save the file instead of displaying it.
44    download: Option<String>,
45    /// The name to save the file as, such as the one a teacher typed into a file block.
46    name: Option<String>,
47}
48
49/// `requested_name`, else the uploaded file's original name, always ending in the file's extension
50/// so the saved file still opens in the right program. The extension comes from the storage path,
51/// or from `stored_name` for paths stored without one.
52fn download_file_name(
53    requested_name: Option<&str>,
54    stored_name: Option<&str>,
55    path: &Path,
56) -> String {
57    let stem_name = path
58        .file_name()
59        .map(|name| name.to_string_lossy().to_string())
60        .unwrap_or_default();
61    let name = [requested_name, stored_name]
62        .into_iter()
63        .flatten()
64        .map(str::trim)
65        .find(|name| !name.is_empty())
66        .unwrap_or(&stem_name);
67    let extension = path
68        .extension()
69        .or_else(|| stored_name.and_then(|stored_name| Path::new(stored_name).extension()))
70        .map(|extension| extension.to_string_lossy());
71    match extension {
72        Some(extension)
73            if !name
74                .to_lowercase()
75                .ends_with(&format!(".{}", extension.to_lowercase())) =>
76        {
77            format!("{name}.{extension}")
78        }
79        _ => name.to_string(),
80    }
81}
82
83/**
84
85GET `/api/v0/files/\*` Redirects the request to a file storage service.
86
87This is meant for redirecting requests to appropriate storage services.
88This approach decouples the storage mechanism from the urls.
89Redirection is done with HTTP status 302 Found and it has a max
90age of 5 minutes.
91
92Redirects to local file handler in development and to a service in production.
93With `?download=1`, the file is served as an attachment under its original name.
94
95
96# Example
97
98`GET /api/v0/files/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg`
99
100Response headers:
101```text
102< HTTP/1.1 302 Found
103< Date: Mon, 26 Apr 2021 10:38:09 GMT
104< Content-Length: 0
105< Connection: keep-alive
106< cache-control: max-age=300, private
107< location: /api/v0/files/uploads/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg
108```
109
110*/
111#[instrument(skip(file_store))]
112#[allow(clippy::async_yields_async)]
113async fn redirect_to_storage_service(
114    tail: web::Path<String>,
115    query: web::Query<FileQuery>,
116    pool: web::Data<PgPool>,
117    file_store: web::Data<dyn FileStore>,
118) -> HttpResponse {
119    let inner = tail.into_inner();
120    let tail_path = Path::new(&inner);
121
122    let url = if query.download.is_some() {
123        let stored_name = match pool.acquire().await {
124            Ok(mut conn) => models::file_uploads::get_details_by_path(&mut conn, &inner)
125                .await
126                .ok()
127                .map(|details| details.name),
128            Err(_) => None,
129        };
130        let file_name =
131            download_file_name(query.name.as_deref(), stored_name.as_deref(), tail_path);
132        file_store
133            .get_direct_attachment_download_url(tail_path, &file_name)
134            .await
135    } else {
136        file_store.get_direct_download_url(tail_path).await
137    };
138    match url {
139        Ok(url) => HttpResponse::Found()
140            .append_header(("location", url))
141            .append_header(("cache-control", "max-age=300, private"))
142            .finish(),
143        Err(e) => {
144            error!("Could not get file {:?}", e);
145            HttpResponse::NotFound()
146                .append_header(("cache-control", "max-age=300, private"))
147                .finish()
148        }
149    }
150}
151
152/**
153GET `/api/v0/files/uploads/\*`
154Serve local uploaded file, mostly for development.
155
156# Example
157
158`GET /api/v0/files/uploads/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg`
159
160Result:
161
162The file.
163*/
164#[instrument(skip(req))]
165async fn serve_upload(
166    req: HttpRequest,
167    query: web::Query<FileQuery>,
168    pool: web::Data<PgPool>,
169) -> ControllerResult<HttpResponse> {
170    let mut conn = pool.acquire().await?;
171
172    // TODO: replace this whole function with the actix_files::Files service once it works with the used actix version.
173    let base_folder = Path::new("uploads");
174    let relative_path = req.match_info().query("tail");
175    let requested_path = Path::new(relative_path);
176    if requested_path.is_absolute()
177        || requested_path.components().any(|component| {
178            matches!(
179                component,
180                Component::ParentDir | Component::RootDir | Component::Prefix(_)
181            )
182        })
183    {
184        return Err(controller_err!(
185            BadRequest,
186            "Invalid upload path".to_string()
187        ));
188    }
189
190    let base_folder = base_folder
191        .canonicalize()
192        .map_err(|_e| controller_err!(NotFound, "File not found".to_string()))?;
193    let path = base_folder
194        .join(requested_path)
195        .canonicalize()
196        .map_err(|_e| controller_err!(NotFound, "File not found".to_string()))?;
197    if !path.starts_with(&base_folder) {
198        return Err(controller_err!(
199            BadRequest,
200            "Invalid upload path".to_string()
201        ));
202    }
203
204    let named_file = NamedFile::open(path).map_err(|_e| {
205        ControllerError::new(
206            ControllerErrorType::NotFound,
207            "File not found".to_string(),
208            None,
209        )
210    })?;
211    let path = named_file.path();
212    let contents = read(path).await.map_err(|_e| {
213        ControllerError::new(
214            ControllerErrorType::InternalServerError,
215            "Could not read file".to_string(),
216            None,
217        )
218    })?;
219
220    let details = models::file_uploads::get_details_by_path(&mut conn, relative_path)
221        .await
222        .optional()?;
223    // Files seeded straight onto disk have no upload row.
224    let mime_type = details.as_ref().map(|d| d.mime.clone()).or_else(|| {
225        let extension = path.extension()?.to_string_lossy().to_string();
226        let mime_type = match extension.as_str() {
227            "jpg" => "image/jpg",
228            "png" => "image/png",
229            "svg" => "image/svg+xml",
230            "webp" => "image/webp",
231            "gif" => "image/gif",
232            "pdf" => "application/pdf",
233            _ => return None,
234        };
235        Some(mime_type.to_string())
236    });
237    let mut response = HttpResponse::Ok();
238    if let Some(m) = mime_type {
239        response.append_header(("content-type", m));
240    }
241    let stored_name = details.as_ref().map(|details| details.name.as_str());
242    if query.download.is_some() {
243        let file_name = download_file_name(query.name.as_deref(), stored_name, path);
244        response.append_header((
245            "Content-Disposition",
246            attachment_content_disposition(&file_name),
247        ));
248    } else if let Some(stored_name) = stored_name {
249        response.append_header((
250            "Content-Disposition",
251            format!("filename=\"{}\"", stored_name),
252        ));
253    }
254
255    // this endpoint is only used for development
256    let token = skip_authorize();
257    token.authorized_ok(response.body(contents))
258}
259
260/**
261POST `/api/v0/files/:exercise_service_slug`
262Used to upload data from exercise service iframes.
263
264# Returns
265An ordered list of host-assigned file ids and stored URLs.
266*/
267#[instrument(skip(payload, file_store, app_conf, upload_claim))]
268#[utoipa::path(
269    post,
270    path = "/{exercise_service_slug}",
271    operation_id = "uploadFilesFromExerciseService",
272    tag = "files",
273    params(
274        ("exercise_service_slug" = String, Path, description = "Exercise service slug")
275    ),
276    request_body(
277        content = inline(std::collections::HashMap<String, ExerciseUploadBinary>),
278        content_type = "multipart/form-data"
279    ),
280    responses(
281        (status = 200, description = "Uploaded files", body = [file_uploading::ExerciseServiceUploadResultEntry])
282    )
283)]
284
285async fn upload_from_exercise_service(
286    pool: web::Data<PgPool>,
287    exercise_service_slug: web::Path<String>,
288    payload: Multipart,
289    file_store: web::Data<dyn FileStore>,
290    user: Option<AuthUser>,
291    upload_claim: Result<UploadClaim, ControllerError>,
292    app_conf: web::Data<ApplicationConfiguration>,
293) -> ControllerResult<web::Json<Vec<file_uploading::ExerciseServiceUploadResultEntry>>> {
294    let mut conn = pool.acquire().await?;
295    // accessed from exercise services, can't authenticate using login,
296    // the upload claim is used to verify requests instead
297    let token = skip_authorize();
298
299    // the playground uses the special "playground" slug to upload temporary files
300    if exercise_service_slug.as_str() != "playground" {
301        // non-playground uploads require a valid upload claim or user
302        match (&upload_claim, &user) {
303            (Ok(upload_claim), _) => {
304                if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
305                    // upload claim's exercise type doesn't match the upload url
306                    return Err(ControllerError::new(
307                        ControllerErrorType::BadRequest,
308                        "Exercise service slug did not match upload claim".to_string(),
309                        None,
310                    ));
311                }
312            }
313            (_, Some(_user)) => {
314                // TODO: for now, all users are allowed to upload files
315            }
316            (Err(_), None) => {
317                return Err(ControllerError::new(
318                    ControllerErrorType::BadRequest,
319                    "Not logged in or missing upload claim".to_string(),
320                    None,
321                ));
322            }
323        }
324    }
325
326    let mut cleanup = file_uploading::UploadCleanup::new(file_store.clone());
327    let uploaded_files = match file_uploading::process_exercise_service_upload(
328        &mut conn,
329        exercise_service_slug.as_str(),
330        payload,
331        file_store.as_ref(),
332        &mut cleanup.uploaded_paths,
333        user.map(|user| user.id),
334        &app_conf,
335    )
336    .await
337    {
338        Ok(uploads) => uploads.into_iter().map(|upload| upload.entry).collect(),
339        Err(outer_err) => {
340            cleanup.clean_up().await;
341            return Err(outer_err);
342        }
343    };
344    cleanup.disarm();
345
346    token.authorized_ok(web::Json(uploaded_files))
347}
348
349/**
350POST `/api/v0/files/answer-uploads/:exercise_task_id`
351Used to upload the files a student is attaching to an answer for the given exercise task.
352
353Unlike `POST /api/v0/files/:exercise_service_slug` this binds every stored file to the uploader and
354the task's exercise, which is what lets a later submission verify that the answer only names files
355the submitter uploaded for that exercise.
356
357# Returns
358An ordered list of `file_uploads` ids and stored URLs, in the order the parts were sent.
359*/
360#[instrument(skip(payload, file_store, app_conf))]
361#[utoipa::path(
362    post,
363    path = "/answer-uploads/{exercise_task_id}",
364    operation_id = "uploadFilesForExerciseAnswer",
365    tag = "files",
366    params(
367        ("exercise_task_id" = Uuid, Path, description = "Exercise task the files are attached to")
368    ),
369    request_body(
370        content = inline(std::collections::HashMap<String, ExerciseUploadBinary>),
371        content_type = "multipart/form-data"
372    ),
373    responses(
374        (status = 200, description = "Uploaded files", body = [file_uploading::ExerciseServiceUploadResultEntry])
375    )
376)]
377async fn upload_answer_files(
378    pool: web::Data<PgPool>,
379    exercise_task_id: web::Path<Uuid>,
380    payload: Multipart,
381    file_store: web::Data<dyn FileStore>,
382    user: AuthUser,
383    app_conf: web::Data<ApplicationConfiguration>,
384) -> ControllerResult<web::Json<Vec<file_uploading::ExerciseServiceUploadResultEntry>>> {
385    let mut conn = pool.acquire().await?;
386    let slide = models::exercise_slides::get_exercise_slide_by_exercise_task_id(
387        &mut conn,
388        *exercise_task_id,
389    )
390    .await?
391    .ok_or_else(|| controller_err!(NotFound, "Exercise task not found".to_string()))?;
392    let token = authorize(
393        &mut conn,
394        Act::View,
395        Some(user.id),
396        Res::ExerciseTask(*exercise_task_id),
397    )
398    .await?;
399    let exercise = models::exercises::get_by_id(&mut conn, slide.exercise_id).await?;
400    // `Act::View` on an exercise task falls through to the organization check, which grants every
401    // logged in user, so the right to answer this particular task has to be established here.
402    domain::exercises::verify_user_can_answer_exercise_slide(
403        &mut conn, user.id, &exercise, slide.id,
404    )
405    .await?;
406
407    let mut cleanup = file_uploading::UploadCleanup::new(file_store.clone());
408    let stored = store_answer_uploads(
409        &mut conn,
410        &file_uploading::AnswerUploadDestination {
411            owner: CourseOrExamId::from_course_and_exam_ids(exercise.course_id, exercise.exam_id)?,
412            exercise_id: slide.exercise_id,
413            user_id: user.id,
414        },
415        payload,
416        file_store.as_ref(),
417        &mut cleanup.uploaded_paths,
418        &app_conf,
419    )
420    .await;
421    let uploads = match stored {
422        Ok(uploads) => uploads,
423        Err(error) => {
424            cleanup.clean_up().await;
425            return Err(error);
426        }
427    };
428    cleanup.disarm();
429
430    let entries = uploads.into_iter().map(|upload| upload.entry).collect();
431    token.authorized_ok(web::Json(entries))
432}
433
434/// The download claim as it rides in the URL the host puts in a grading request.
435#[derive(Debug, Deserialize)]
436struct DownloadClaimQuery {
437    #[serde(rename = "download-claim")]
438    download_claim: String,
439}
440
441/**
442GET `/api/v0/files/claimed/:file_upload_id?download-claim=:jwt`
443Redirects to one host-stored file, authorized by a claim naming that file.
444
445Used by exercise services grading a file-typed answer: the claim, not a session, is the
446authorization, and it names a single file so a service cannot reach any other one.
447*/
448#[instrument(skip(file_store, jwt_key, query))]
449async fn redirect_claimed_file(
450    file_upload_id: web::Path<Uuid>,
451    query: web::Query<DownloadClaimQuery>,
452    pool: web::Data<PgPool>,
453    file_store: web::Data<dyn FileStore>,
454    jwt_key: web::Data<JwtKey>,
455) -> ControllerResult<HttpResponse> {
456    // accessed from exercise services, which cannot authenticate using login
457    let token = skip_authorize();
458    let claim = DownloadClaim::validate(&query.download_claim, &jwt_key)
459        .map_err(|err| controller_err!(BadRequest, format!("Invalid jwt key: {err}"), err))?;
460    if claim.file_upload_id() != *file_upload_id {
461        return Err(controller_err!(
462            BadRequest,
463            "Download claim does not match the requested file".to_string()
464        ));
465    }
466
467    let mut conn = pool.acquire().await?;
468    let file = models::file_uploads::get_many(&mut conn, &[*file_upload_id])
469        .await?
470        .pop()
471        .ok_or_else(|| controller_err!(NotFound, "File not found".to_string()))?;
472    let url = file_store
473        .get_direct_download_url(Path::new(&file.path))
474        .await
475        .map_err(|err| controller_err!(NotFound, "File not found".to_string(), err))?;
476
477    token.authorized_ok(
478        HttpResponse::Found()
479            .append_header(("location", url))
480            .append_header(("cache-control", "max-age=300, private"))
481            .finish(),
482    )
483}
484
485/// Stores the multipart parts and binds them to the exercise and user, so that a failure to record
486/// the binding cannot leave uploads the reaper is unable to find.
487///
488/// The transaction opens only after the last byte has been streamed: the multipart body has no time
489/// limit, so opening it first would pin a pool connection `idle in transaction` for the whole
490/// upload.
491async fn store_answer_uploads(
492    conn: &mut PgConnection,
493    destination: &file_uploading::AnswerUploadDestination,
494    payload: Multipart,
495    file_store: &dyn FileStore,
496    uploaded_paths: &mut Vec<file_uploading::ExerciseServiceUploadCleanup>,
497    app_conf: &ApplicationConfiguration,
498) -> Result<Vec<file_uploading::ExerciseServiceUpload>, ControllerError> {
499    let streamed = file_uploading::stream_exercise_service_upload(
500        file_uploading::UploadPathScheme::Answer(destination),
501        payload,
502        file_store,
503        uploaded_paths,
504        app_conf,
505    )
506    .await?;
507
508    let mut tx = conn.begin().await?;
509    let uploads = file_uploading::record_exercise_service_upload(
510        &mut tx,
511        streamed,
512        Some(destination.user_id),
513    )
514    .await?;
515    let file_upload_ids: Vec<Uuid> = uploads.iter().map(|upload| upload.entry.id).collect();
516    models::exercise_answer_uploads::insert_many(
517        &mut tx,
518        destination.exercise_id,
519        destination.user_id,
520        &file_upload_ids,
521        models::exercise_answer_uploads::AnswerUploadOrigin::Iframe,
522    )
523    .await?;
524    tx.commit().await?;
525    Ok(uploads)
526}
527
528/**
529Add a route for each controller in this module.
530
531The name starts with an underline in order to appear before other functions in the module documentation.
532
533We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
534*/
535pub fn _add_routes(cfg: &mut ServiceConfig) {
536    cfg.route("/uploads/{tail:.*}", web::get().to(serve_upload))
537        .route(
538            "/answer-uploads/{exercise_task_id}",
539            web::post().to(upload_answer_files),
540        )
541        .route(
542            "/claimed/{file_upload_id}",
543            web::get().to(redirect_claimed_file),
544        )
545        .route(
546            "/{exercise_service_slug}",
547            web::post().to(upload_from_exercise_service),
548        )
549        .route("{tail:.*}", web::get().to(redirect_to_storage_service));
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn exercise_upload_openapi_body_is_a_string_keyed_binary_map() {
558        let document = serde_json::to_value(FilesApiDoc::openapi()).unwrap();
559        let schema = document
560            .pointer("/paths/~1{exercise_service_slug}/post/requestBody/content/multipart~1form-data/schema")
561            .unwrap();
562
563        assert_eq!(schema["type"], "object");
564        assert_eq!(schema["additionalProperties"]["type"], "string");
565        assert_eq!(schema["additionalProperties"]["format"], "binary");
566    }
567}
568
569#[cfg(test)]
570mod answer_upload_tests {
571    use super::*;
572    use crate::domain::models_requests::JwtKey;
573    use crate::test_helper::*;
574    use actix_session::{SessionMiddleware, storage::CookieSessionStore};
575    use actix_web::cookie::{Cookie, Key, SameSite};
576    use actix_web::http::StatusCode;
577    use actix_web::{App, test};
578    use chrono::{Duration, Utc};
579    use models::exercise_answer_uploads::AnswerUploadBinding;
580    use models::exercise_answer_uploads::AnswerUploadOrigin;
581    use std::sync::Arc;
582
583    const BOUNDARY: &str = "answeruploadboundary";
584    const SESSION_KEY_BYTES: &[u8] =
585        b"answer-upload-tests-cookie-signing-key-that-is-long-enough-abcdef";
586
587    /// Puts a user into the session without going through a login flow, so `AuthUser` resolves.
588    async fn test_login(user_id: web::Path<Uuid>, session: actix_session::Session) -> HttpResponse {
589        let now = Utc::now();
590        crate::domain::authentication::remember(
591            &session,
592            models::users::User {
593                id: *user_id,
594                created_at: now,
595                updated_at: now,
596                deleted_at: None,
597                upstream_id: None,
598                email_domain: None,
599            },
600        )
601        .expect("remember the user");
602        HttpResponse::Ok().finish()
603    }
604
605    /// The routes under a real actix app, mounted where they are mounted in production so the path
606    /// the tests send is the path a browser sends.
607    macro_rules! files_app {
608        () => {{
609            let pool = PgPool::connect(&dev_database_url()).await.expect("pool");
610            let file_store: Arc<dyn FileStore> = Arc::new(temp_file_store());
611            test::init_service(
612                App::new()
613                    .app_data(web::Data::new(pool))
614                    .app_data(web::Data::from(file_store))
615                    .app_data(web::Data::new(init_app_conf().expect("app conf")))
616                    .app_data(web::Data::new(JwtKey::test_key()))
617                    .service(
618                        web::resource("/test-login/{user_id}").route(web::post().to(test_login)),
619                    )
620                    .service(web::scope("/api/v0/files").configure(_add_routes))
621                    .wrap(
622                        SessionMiddleware::builder(
623                            CookieSessionStore::default(),
624                            Key::from(SESSION_KEY_BYTES),
625                        )
626                        .cookie_secure(false)
627                        .cookie_same_site(SameSite::Lax)
628                        .cookie_path("/".to_string())
629                        .build(),
630                    ),
631            )
632            .await
633        }};
634    }
635
636    macro_rules! login {
637        ($app:expr, $user:expr) => {{
638            let request = test::TestRequest::post()
639                .uri(&format!("/test-login/{}", $user))
640                .to_request();
641            let response = test::call_service(&$app, request).await;
642            assert_eq!(response.status(), StatusCode::OK);
643            response
644                .response()
645                .cookies()
646                .next()
647                .expect("session cookie")
648                .into_owned()
649        }};
650    }
651
652    fn multipart_body(parts: &[(Uuid, &str, &str)]) -> Vec<u8> {
653        let mut body = String::new();
654        for (field_name, file_name, contents) in parts {
655            body.push_str(&format!("--{BOUNDARY}\r\n"));
656            body.push_str(&format!(
657                "Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{file_name}\"\r\n"
658            ));
659            body.push_str("Content-Type: application/octet-stream\r\n\r\n");
660            body.push_str(contents);
661            body.push_str("\r\n");
662        }
663        body.push_str(&format!("--{BOUNDARY}--\r\n"));
664        body.into_bytes()
665    }
666
667    fn upload_request(
668        uri: &str,
669        session: Option<&Cookie<'static>>,
670        parts: &[(Uuid, &str, &str)],
671    ) -> test::TestRequest {
672        let mut request = test::TestRequest::post()
673            .uri(uri)
674            .insert_header((
675                "Content-Type",
676                format!("multipart/form-data; boundary={BOUNDARY}"),
677            ))
678            .set_payload(multipart_body(parts));
679        if let Some(cookie) = session {
680            request = request.cookie(cookie.clone());
681        }
682        request
683    }
684
685    fn answer_upload_uri(exercise_task_id: Uuid) -> String {
686        format!("/api/v0/files/answer-uploads/{exercise_task_id}")
687    }
688
689    #[actix_web::test]
690    async fn answer_uploads_reject_anonymous_callers() {
691        let app = files_app!();
692        let request = upload_request(
693            &answer_upload_uri(Uuid::new_v4()),
694            None,
695            &[(Uuid::new_v4(), "a.txt", "first")],
696        )
697        .to_request();
698
699        let response = test::call_service(&app, request).await;
700
701        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
702    }
703
704    #[actix_web::test]
705    async fn answer_uploads_to_a_missing_exercise_task_are_not_found() {
706        let app = files_app!();
707        let cookie = login!(app, Uuid::new_v4());
708        let request = upload_request(
709            &answer_upload_uri(Uuid::new_v4()),
710            Some(&cookie),
711            &[(Uuid::new_v4(), "a.txt", "first")],
712        )
713        .to_request();
714
715        let response = test::call_service(&app, request).await;
716
717        assert_eq!(response.status(), StatusCode::NOT_FOUND);
718    }
719
720    /// A literal first segment must reach this handler rather than the slug route's wildcard, which
721    /// would silently accept the upload without binding it to anyone.
722    #[actix_web::test]
723    async fn the_answer_upload_path_is_not_shadowed_by_the_slug_route() {
724        let app = files_app!();
725        let parts = [(Uuid::new_v4(), "a.txt", "first")];
726
727        let bound = test::call_service(
728            &app,
729            upload_request(&answer_upload_uri(Uuid::new_v4()), None, &parts).to_request(),
730        )
731        .await;
732        let by_slug = test::call_service(
733            &app,
734            upload_request("/api/v0/files/example-exercise", None, &parts).to_request(),
735        )
736        .await;
737
738        // The slug route rejects an anonymous caller as unprocessable, this one as unauthorized.
739        assert_eq!(bound.status(), StatusCode::UNAUTHORIZED);
740        assert_eq!(by_slug.status(), StatusCode::UNPROCESSABLE_ENTITY);
741    }
742
743    /// The ids of a committed course fixture the answer-upload route can be pointed at.
744    struct CourseTask {
745        user: Uuid,
746        exercise: Uuid,
747        task: Uuid,
748    }
749
750    /// Builds a course with one exercise task, optionally enrolling the user on it.
751    ///
752    /// Committed because the handler runs on a pool connection of its own and cannot see fixtures
753    /// left uncommitted.
754    async fn course_task(enrolled: bool) -> CourseTask {
755        insert_data!(:tx, user: user, :org, :course, instance: instance, :course_module, :chapter, :page, exercise: exercise, :slide, task: task);
756        if enrolled {
757            models::course_instance_enrollments::insert_enrollment_and_set_as_current(
758                tx.as_mut(),
759                models::course_instance_enrollments::NewCourseInstanceEnrollment {
760                    course_id: course,
761                    course_instance_id: instance.id,
762                    user_id: user,
763                },
764            )
765            .await
766            .expect("the enrollment");
767        }
768        tx.commit().await;
769        CourseTask {
770            user,
771            exercise,
772            task,
773        }
774    }
775
776    /// Commits a passed deadline onto the fixture's exercise, which is an exercise setting rather
777    /// than user state and so has to be visible to the request's own connection.
778    async fn expire_deadline(exercise: Uuid) {
779        let mut conn = Conn::init().await;
780        let mut tx = conn.begin().await;
781        models::exercises::set_deadline(
782            tx.as_mut(),
783            exercise,
784            Some(Utc::now() - Duration::days(1)),
785        )
786        .await
787        .expect("the deadline update");
788        tx.commit().await;
789    }
790
791    /// Commits a try limit of zero, so every slide of the fixture's exercise is already exhausted.
792    async fn exhaust_tries(exercise: Uuid) {
793        let mut conn = Conn::init().await;
794        let mut tx = conn.begin().await;
795        models::exercises::set_try_limit(tx.as_mut(), exercise, true, Some(0))
796            .await
797            .expect("the try limit update");
798        tx.commit().await;
799    }
800
801    async fn upload_status(fixture: &CourseTask) -> StatusCode {
802        let app = files_app!();
803        let cookie = login!(app, fixture.user);
804        let request = upload_request(
805            &answer_upload_uri(fixture.task),
806            Some(&cookie),
807            &[(Uuid::new_v4(), "a.txt", "first")],
808        )
809        .to_request();
810
811        test::call_service(&app, request).await.status()
812    }
813
814    #[actix_web::test]
815    async fn answer_uploads_from_a_user_who_is_not_enrolled_are_rejected() {
816        let fixture = course_task(false).await;
817
818        assert_eq!(upload_status(&fixture).await, StatusCode::UNAUTHORIZED);
819    }
820
821    #[actix_web::test]
822    async fn answer_uploads_past_the_exercise_deadline_are_rejected() {
823        let fixture = course_task(true).await;
824        expire_deadline(fixture.exercise).await;
825
826        assert_eq!(
827            upload_status(&fixture).await,
828            StatusCode::UNPROCESSABLE_ENTITY
829        );
830    }
831
832    #[actix_web::test]
833    async fn answer_uploads_from_a_user_out_of_tries_are_rejected() {
834        let fixture = course_task(true).await;
835        exhaust_tries(fixture.exercise).await;
836
837        assert_eq!(
838            upload_status(&fixture).await,
839            StatusCode::UNPROCESSABLE_ENTITY
840        );
841    }
842
843    #[actix_web::test]
844    async fn answer_uploads_return_bound_database_ids_in_request_order() {
845        let CourseTask {
846            user,
847            exercise,
848            task,
849        } = course_task(true).await;
850        let app = files_app!();
851        let cookie = login!(app, user);
852        let first_field = Uuid::new_v4();
853        let second_field = Uuid::new_v4();
854        let request = upload_request(
855            &answer_upload_uri(task),
856            Some(&cookie),
857            &[
858                (first_field, "a.tar.zst", "first"),
859                (second_field, "b.txt", "second"),
860            ],
861        )
862        .to_request();
863
864        let response = test::call_service(&app, request).await;
865        assert_eq!(response.status(), StatusCode::OK);
866        let entries: Vec<serde_json::Value> = test::read_body_json(response).await;
867
868        let ids: Vec<Uuid> = entries
869            .iter()
870            .map(|entry| {
871                Uuid::parse_str(entry["id"].as_str().expect("an id string")).expect("a uuid id")
872            })
873            .collect();
874        assert_eq!(ids.len(), 2);
875        assert!(!ids.contains(&first_field) && !ids.contains(&second_field));
876
877        let mut conn = Conn::init().await;
878        let mut check = conn.begin().await;
879        let stored = models::file_uploads::get_many(check.as_mut(), &ids)
880            .await
881            .expect("file uploads");
882        let names: Vec<&str> = ids
883            .iter()
884            .map(|id| {
885                stored
886                    .iter()
887                    .find(|file| &file.id == id)
888                    .map(|file| file.name.as_str())
889                    .expect("a file upload row for every returned id")
890            })
891            .collect();
892        assert_eq!(names, vec!["a.tar.zst", "b.txt"]);
893
894        let bindings =
895            models::exercise_answer_uploads::get_by_file_upload_ids(check.as_mut(), &ids)
896                .await
897                .expect("the bindings");
898        assert_eq!(bindings.len(), 2);
899        for AnswerUploadBinding {
900            file_upload_id,
901            exercise_id,
902            user_id,
903            origin,
904        } in bindings
905        {
906            assert!(ids.contains(&file_upload_id));
907            assert_eq!(exercise_id, exercise);
908            assert_eq!(user_id, user);
909            assert_eq!(origin, AnswerUploadOrigin::Iframe);
910        }
911        check.rollback().await;
912    }
913}
914
915#[cfg(test)]
916mod claimed_file_tests {
917    use super::*;
918    use crate::domain::models_requests::DOWNLOAD_CLAIM_PARAM;
919    use crate::test_helper::*;
920    use actix_web::http::StatusCode;
921    use actix_web::{App, test};
922    use std::sync::Arc;
923
924    macro_rules! claimed_files_app {
925        ($file_store:expr) => {{
926            let pool = PgPool::connect(&dev_database_url()).await.expect("pool");
927            test::init_service(
928                App::new()
929                    .app_data(web::Data::new(pool))
930                    .app_data(web::Data::from($file_store))
931                    .app_data(web::Data::new(JwtKey::test_key()))
932                    .service(web::scope("/api/v0/files").configure(_add_routes)),
933            )
934            .await
935        }};
936    }
937
938    fn claimed_uri(file_upload_id: Uuid, claim: &str) -> String {
939        format!("/api/v0/files/claimed/{file_upload_id}?{DOWNLOAD_CLAIM_PARAM}={claim}")
940    }
941
942    fn claim_for(file_upload_id: Uuid) -> String {
943        DownloadClaim::expiring_in_1_day(file_upload_id)
944            .sign(&JwtKey::test_key())
945            .expect("signing should succeed")
946    }
947
948    /// A stored object with a `file_uploads` row pointing at it.
949    async fn stored_file(store: &Arc<dyn FileStore>) -> (String, Uuid) {
950        let path = format!("claimed-file-tests/{}.txt", Uuid::new_v4());
951        store
952            .upload(Path::new(&path), b"contents".to_vec(), "text/plain")
953            .await
954            .expect("the stored object");
955        let id = insert_file_upload(&path).await;
956        (path, id)
957    }
958
959    /// Records a stored object, committing so the handler's own connection can see it.
960    async fn insert_file_upload(path: &str) -> Uuid {
961        let mut conn = Conn::init().await;
962        let mut tx = conn.begin().await;
963        let id = models::file_uploads::insert(
964            tx.as_mut(),
965            "answer.txt",
966            path,
967            "text/plain",
968            None,
969            Some(8),
970        )
971        .await
972        .expect("the file upload row");
973        tx.commit().await;
974        id
975    }
976
977    async fn soft_delete_file_upload(id: Uuid) {
978        let mut conn = Conn::init().await;
979        let mut tx = conn.begin().await;
980        models::file_uploads::delete_and_fetch_path(tx.as_mut(), id)
981            .await
982            .expect("the file upload row");
983        tx.commit().await;
984    }
985
986    #[actix_web::test]
987    async fn a_claimed_file_redirects_to_the_store_url() {
988        let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
989        let (path, id) = stored_file(&store).await;
990        let expected_url = store
991            .get_direct_download_url(Path::new(&path))
992            .await
993            .expect("the store url");
994        let app = claimed_files_app!(Arc::clone(&store));
995
996        let response = test::call_service(
997            &app,
998            test::TestRequest::get()
999                .uri(&claimed_uri(id, &claim_for(id)))
1000                .to_request(),
1001        )
1002        .await;
1003
1004        assert_eq!(response.status(), StatusCode::FOUND);
1005        assert_eq!(
1006            response
1007                .headers()
1008                .get("location")
1009                .expect("a location header"),
1010            expected_url.as_str()
1011        );
1012    }
1013
1014    /// Naming a single file is what keeps a service from reaching any other one, so a claim must
1015    /// not authorize the file the path names.
1016    #[actix_web::test]
1017    async fn a_claim_for_another_file_is_rejected() {
1018        let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1019        let (_path, id) = stored_file(&store).await;
1020        let app = claimed_files_app!(Arc::clone(&store));
1021
1022        let response = test::call_service(
1023            &app,
1024            test::TestRequest::get()
1025                .uri(&claimed_uri(id, &claim_for(Uuid::new_v4())))
1026                .to_request(),
1027        )
1028        .await;
1029
1030        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1031    }
1032
1033    #[actix_web::test]
1034    async fn a_tampered_claim_is_rejected() {
1035        let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1036        let app = claimed_files_app!(Arc::clone(&store));
1037        let id = Uuid::new_v4();
1038        let mut claim = claim_for(id);
1039        claim.pop();
1040
1041        let response = test::call_service(
1042            &app,
1043            test::TestRequest::get()
1044                .uri(&claimed_uri(id, &claim))
1045                .to_request(),
1046        )
1047        .await;
1048
1049        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1050    }
1051
1052    #[actix_web::test]
1053    async fn a_request_without_a_claim_is_rejected() {
1054        let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1055        let app = claimed_files_app!(Arc::clone(&store));
1056
1057        let response = test::call_service(
1058            &app,
1059            test::TestRequest::get()
1060                .uri(&format!("/api/v0/files/claimed/{}", Uuid::new_v4()))
1061                .to_request(),
1062        )
1063        .await;
1064
1065        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1066    }
1067
1068    #[actix_web::test]
1069    async fn a_soft_deleted_file_is_not_found() {
1070        let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1071        let (_path, id) = stored_file(&store).await;
1072        soft_delete_file_upload(id).await;
1073        let app = claimed_files_app!(Arc::clone(&store));
1074
1075        let response = test::call_service(
1076            &app,
1077            test::TestRequest::get()
1078                .uri(&claimed_uri(id, &claim_for(id)))
1079                .to_request(),
1080        )
1081        .await;
1082
1083        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1084    }
1085
1086    /// A literal first segment must reach this handler rather than the catch-all, which serves any
1087    /// path with no authorization at all.
1088    #[actix_web::test]
1089    async fn the_claimed_file_path_is_not_shadowed_by_the_catch_all() {
1090        let store: Arc<dyn FileStore> = Arc::new(temp_file_store());
1091        let path = format!("claimed/{}", Uuid::new_v4());
1092        store
1093            .upload(Path::new(&path), b"contents".to_vec(), "text/plain")
1094            .await
1095            .expect("the stored object");
1096        let app = claimed_files_app!(Arc::clone(&store));
1097
1098        let response = test::call_service(
1099            &app,
1100            test::TestRequest::get()
1101                .uri(&format!("/api/v0/files/{path}"))
1102                .to_request(),
1103        )
1104        .await;
1105
1106        // Reaching the catch-all would redirect to the object that is really there; this handler
1107        // instead refuses a request carrying no claim.
1108        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1109    }
1110}