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::path::{Component, Path};
10use tokio::fs::read;
11use utoipa::{OpenApi, PartialSchema, ToSchema};
12
13struct 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#[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#[instrument(skip(req))]
95async fn serve_upload(req: HttpRequest, pool: web::Data<PgPool>) -> ControllerResult<HttpResponse> {
96 let mut conn = pool.acquire().await?;
97
98 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 let token = skip_authorize();
171 token.authorized_ok(response.body(contents))
172}
173
174#[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 let token = skip_authorize();
212
213 if exercise_service_slug.as_str() != "playground" {
215 match (&upload_claim, &user) {
217 (Ok(upload_claim), _) => {
218 if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
219 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 }
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 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
270pub 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}