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