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