headless_lms_server/controllers/
files.rs1use 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#[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#[instrument(skip(req))]
82async fn serve_upload(req: HttpRequest, pool: web::Data<PgPool>) -> ControllerResult<HttpResponse> {
83 let mut conn = pool.acquire().await?;
84
85 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 let token = skip_authorize();
158 token.authorized_ok(response.body(contents))
159}
160
161#[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 let token = skip_authorize();
199
200 if exercise_service_slug.as_str() != "playground" {
202 match (&upload_claim, &user) {
204 (Ok(upload_claim), _) => {
205 if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
206 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 }
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 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
251pub 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}