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