headless_lms_server/controllers/
files.rs

1/*!
2Handlers for HTTP requests to `/api/v0/files`.
3
4*/
5use super::helpers::file_uploading;
6pub use crate::domain::{authorization::AuthorizationToken, models_requests::UploadClaim};
7use crate::prelude::*;
8use actix_files::NamedFile;
9use std::{collections::HashMap, path::Path};
10use tokio::fs::read;
11use utoipa::OpenApi;
12
13#[derive(OpenApi)]
14#[openapi(paths(upload_from_exercise_service))]
15pub(crate) struct FilesApiDoc;
16/**
17
18GET `/api/v0/files/\*` Redirects the request to a file storage service.
19
20This is meant for redirecting requests to appropriate storage services.
21This approach decouples the storage mechanism from the urls.
22Redirection is done with HTTP status 302 Found and it has a max
23age of 5 minutes.
24
25Redirects to local file handler in development and to a service in production.
26
27
28# Example
29
30`GET /api/v0/files/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg`
31
32Response headers:
33```text
34< HTTP/1.1 302 Found
35< Date: Mon, 26 Apr 2021 10:38:09 GMT
36< Content-Length: 0
37< Connection: keep-alive
38< cache-control: max-age=300, private
39< location: /api/v0/files/uploads/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg
40```
41
42*/
43#[instrument(skip(file_store))]
44#[allow(clippy::async_yields_async)]
45async fn redirect_to_storage_service(
46    tail: web::Path<String>,
47    file_store: web::Data<dyn FileStore>,
48) -> HttpResponse {
49    let inner = tail.into_inner();
50    let tail_path = Path::new(&inner);
51
52    match file_store.get_direct_download_url(tail_path).await {
53        Ok(url) => HttpResponse::Found()
54            .append_header(("location", url))
55            .append_header(("cache-control", "max-age=300, private"))
56            .finish(),
57        Err(e) => {
58            error!("Could not get file {:?}", e);
59            HttpResponse::NotFound()
60                .append_header(("cache-control", "max-age=300, private"))
61                .finish()
62        }
63    }
64}
65
66/**
67GET `/api/v0/files/uploads/\*`
68Serve local uploaded file, mostly for development.
69
70# Example
71
72`GET /api/v0/files/uploads/organizations/1b89e57e-8b57-42f2-9fed-c7a6736e3eec/courses/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/images/nNQbVax81fH4SLCXuQ9NrOWtqfHT6x.jpg`
73
74Result:
75
76The file.
77*/
78#[instrument(skip(req))]
79async fn serve_upload(req: HttpRequest, pool: web::Data<PgPool>) -> ControllerResult<HttpResponse> {
80    let mut conn = pool.acquire().await?;
81
82    // TODO: replace this whole function with the actix_files::Files service once it works with the used actix version.
83    let base_folder = Path::new("uploads");
84    let relative_path = req.match_info().query("tail");
85    let path = base_folder.join(relative_path);
86
87    let named_file = NamedFile::open(path).map_err(|_e| {
88        ControllerError::new(
89            ControllerErrorType::NotFound,
90            "File not found".to_string(),
91            None,
92        )
93    })?;
94    let path = named_file.path();
95    let contents = read(path).await.map_err(|_e| {
96        ControllerError::new(
97            ControllerErrorType::InternalServerError,
98            "Could not read file".to_string(),
99            None,
100        )
101    })?;
102
103    let extension = path.extension().map(|o| o.to_string_lossy().to_string());
104    let mut mime_type = None;
105    if let Some(ext_string) = extension {
106        mime_type = match ext_string.as_str() {
107            "jpg" => Some("image/jpg"),
108            "png" => Some("image/png"),
109            "svg" => Some("image/svg+xml"),
110            "webp" => Some("image/webp"),
111            "gif" => Some("image/gif"),
112            _ => None,
113        };
114    }
115    let mut response = HttpResponse::Ok();
116    if let Some(m) = mime_type {
117        response.append_header(("content-type", m));
118    }
119    if let Some(filename) = models::file_uploads::get_filename(&mut conn, relative_path)
120        .await
121        .optional()?
122    {
123        response.append_header(("Content-Disposition", format!("filename=\"{}\"", filename)));
124    }
125
126    // this endpoint is only used for development
127    let token = skip_authorize();
128    token.authorized_ok(response.body(contents))
129}
130
131/**
132POST `/api/v0/files/:exercise_service_slug`
133Used to upload data from exercise service iframes.
134
135# Returns
136The randomly generated paths to each uploaded file in a `file_name => file_path` hash map.
137*/
138#[instrument(skip(payload, file_store, app_conf, upload_claim))]
139#[utoipa::path(
140    post,
141    path = "/{exercise_service_slug}",
142    operation_id = "uploadFilesFromExerciseService",
143    tag = "files",
144    params(
145        ("exercise_service_slug" = String, Path, description = "Exercise service slug")
146    ),
147    request_body(
148        content = String,
149        content_type = "multipart/form-data"
150    ),
151    responses(
152        (status = 200, description = "Uploaded files", body = HashMap<String, String>)
153    )
154)]
155
156async fn upload_from_exercise_service(
157    pool: web::Data<PgPool>,
158    exercise_service_slug: web::Path<String>,
159    payload: Multipart,
160    file_store: web::Data<dyn FileStore>,
161    user: Option<AuthUser>,
162    upload_claim: Result<UploadClaim<'static>, ControllerError>,
163    app_conf: web::Data<ApplicationConfiguration>,
164) -> ControllerResult<web::Json<HashMap<String, String>>> {
165    let mut conn = pool.acquire().await?;
166    // accessed from exercise services, can't authenticate using login,
167    // the upload claim is used to verify requests instead
168    let token = skip_authorize();
169
170    // the playground uses the special "playground" slug to upload temporary files
171    if exercise_service_slug.as_str() != "playground" {
172        // non-playground uploads require a valid upload claim or user
173        match (&upload_claim, &user) {
174            (Ok(upload_claim), _) => {
175                if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
176                    // upload claim's exercise type doesn't match the upload url
177                    return Err(ControllerError::new(
178                        ControllerErrorType::BadRequest,
179                        "Exercise service slug did not match upload claim".to_string(),
180                        None,
181                    ));
182                }
183            }
184            (_, Some(_user)) => {
185                // TODO: for now, all users are allowed to upload files
186            }
187            (Err(_), None) => {
188                return Err(ControllerError::new(
189                    ControllerErrorType::BadRequest,
190                    "Not logged in or missing upload claim".to_string(),
191                    None,
192                ));
193            }
194        }
195    }
196
197    let mut paths = HashMap::new();
198    if let Err(outer_err) = file_uploading::process_exercise_service_upload(
199        &mut conn,
200        exercise_service_slug.as_str(),
201        payload,
202        file_store.as_ref(),
203        &mut paths,
204        user,
205        &app_conf.base_url,
206    )
207    .await
208    {
209        // something went wrong while uploading the files, try to delete leftovers
210        for path in paths.values() {
211            if let Err(err) = file_store.delete(Path::new(path)).await {
212                error!("Failed to delete file '{path}' during cleanup: {err}")
213            }
214        }
215        return Err(outer_err);
216    }
217
218    token.authorized_ok(web::Json(paths))
219}
220
221/**
222Add a route for each controller in this module.
223
224The name starts with an underline in order to appear before other functions in the module documentation.
225
226We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
227*/
228pub fn _add_routes(cfg: &mut ServiceConfig) {
229    cfg.route("/uploads/{tail:.*}", web::get().to(serve_upload))
230        .route(
231            "/{exercise_service_slug}",
232            web::post().to(upload_from_exercise_service),
233        )
234        .route("{tail:.*}", web::get().to(redirect_to_storage_service));
235}