1pub use crate::domain::authorization::AuthorizationToken;
4use crate::prelude::*;
5use actix_http::header::HeaderMap;
6use actix_multipart as mp;
7use actix_multipart::Field;
8use actix_web::http::header;
9use futures::{StreamExt, TryStreamExt};
10use headless_lms_utils::file_store::{FileStore, GenericPayload};
11use headless_lms_utils::{
12 file_store::file_utils::get_extension_from_filename, strings::generate_random_string,
13};
14use mime::Mime;
15use models::exercise_slides::ExerciseSlide;
16use models::exercise_tasks::ExerciseTask;
17use models::exercises::Exercise;
18use models::organizations::DatabaseOrganization;
19use rand::distr::Alphanumeric;
20use rand::distr::SampleString;
21use std::{collections::HashMap, path::Path};
22use std::{path::PathBuf, sync::Arc};
23
24pub async fn process_exercise_service_upload(
27 conn: &mut PgConnection,
28 exercise_service_slug: &str,
29 mut payload: Multipart,
30 file_store: &dyn FileStore,
31 paths: &mut HashMap<String, String>,
32 uploader: Option<AuthUser>,
33 base_url: &str,
34) -> Result<(), ControllerError> {
35 let mut tx = conn.begin().await?;
36 while let Some(item) = payload.next().await {
37 let field = item.map_err(|err| {
38 ControllerError::new(
39 ControllerErrorType::InternalServerError,
40 format!("Failed to read multipart field: {}", err),
41 Some(anyhow::anyhow!("Multipart error: {}", err)),
42 )
43 })?;
44 let field_name = {
45 let name_ref = field.name().ok_or_else(|| {
46 ControllerError::new(
47 ControllerErrorType::BadRequest,
48 "Tried to upload a file without a file name".to_string(),
49 None,
50 )
51 })?;
52 name_ref.to_string()
53 };
54
55 let random_filename = generate_random_string(32);
56 let path = format!("{exercise_service_slug}/{random_filename}");
57
58 upload_field_to_storage(&mut tx, Path::new(&path), field, file_store, uploader).await?;
59 let url = format!("{base_url}/api/v0/files/{path}");
60 paths.insert(field_name, url);
61 }
62 tx.commit().await?;
63 Ok(())
64}
65
66#[derive(Debug, Clone, Copy, Deserialize)]
67#[cfg_attr(feature = "ts_rs", derive(TS))]
68pub enum StoreKind {
69 Organization(Uuid),
70 Course(Uuid),
71 Exam(Uuid),
72}
73
74pub async fn upload_file_from_cms(
76 headers: &HeaderMap,
77 mut payload: Multipart,
78 store_kind: StoreKind,
79 file_store: &dyn FileStore,
80 conn: &mut PgConnection,
81 user: AuthUser,
82) -> Result<PathBuf, ControllerError> {
83 let file_payload = payload.next().await.ok_or_else(|| {
84 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
85 })?;
86 match file_payload {
87 Ok(field) => {
88 upload_field_from_cms(headers, field, store_kind, file_store, conn, user).await
89 }
90 Err(err) => Err(ControllerError::new(
91 ControllerErrorType::InternalServerError,
92 err.to_string(),
93 None,
94 )),
95 }
96}
97
98pub async fn upload_field_from_cms(
100 headers: &HeaderMap,
101 field: Field,
102 store_kind: StoreKind,
103 file_store: &dyn FileStore,
104 conn: &mut PgConnection,
105 user: AuthUser,
106) -> Result<PathBuf, ControllerError> {
107 validate_media_headers(headers, &user, conn).await?;
108 let path = match field.content_type().map(|ct| ct.type_()) {
109 Some(mime::AUDIO) => generate_audio_path(&field, store_kind)?,
110 Some(mime::IMAGE) => generate_image_path(&field, store_kind)?,
111 _ => generate_file_path(&field, store_kind)?,
112 };
113 upload_field_to_storage(conn, &path, field, file_store, Some(user)).await?;
114 Ok(path)
115}
116
117pub async fn upload_image_for_organization(
119 headers: &HeaderMap,
120 mut payload: Multipart,
121 organization: &DatabaseOrganization,
122 file_store: &Arc<dyn FileStore>,
123 user: AuthUser,
124 conn: &mut PgConnection,
125) -> Result<PathBuf, ControllerError> {
126 validate_media_headers(headers, &user, conn).await?;
127 let next_payload: Result<Field, mp::MultipartError> =
128 payload.next().await.ok_or_else(|| {
129 ControllerError::new(ControllerErrorType::BadRequest, "Missing form data", None)
130 })?;
131 match next_payload {
132 Ok(field) => {
133 let path: PathBuf = match field.content_type().map(|ct| ct.type_()) {
134 Some(mime::IMAGE) => {
135 generate_image_path(&field, StoreKind::Organization(organization.id))
136 }
137 Some(unsupported) => Err(ControllerError::new(
138 ControllerErrorType::BadRequest,
139 format!("Unsupported image Mime type: {}", unsupported),
140 None,
141 )),
142 None => Err(ControllerError::new(
143 ControllerErrorType::BadRequest,
144 "Missing image Mime type",
145 None,
146 )),
147 }?;
148 upload_field_to_storage(conn, &path, field, file_store.as_ref(), Some(user)).await?;
149 Ok(path)
150 }
151 Err(err) => Err(ControllerError::new(
152 ControllerErrorType::InternalServerError,
153 err.to_string(),
154 None,
155 )),
156 }
157}
158
159const FILE_SIZE_LIMITS: &[(mime::Name, i32)] = &[
163 (mime::IMAGE, 10 * 1024 * 1024),
165 (mime::AUDIO, 100 * 1024 * 1024),
167 (mime::VIDEO, 100 * 1024 * 1024),
169 (mime::APPLICATION, 25 * 1024 * 1024),
171];
172const DEFAULT_FILE_SIZE_LIMIT: i32 = 10 * 1024 * 1024;
174
175fn get_size_limit_for_mime(mime_type: Option<mime::Name>) -> i32 {
176 mime_type
177 .and_then(|mime| FILE_SIZE_LIMITS.iter().find(|(m, _)| *m == mime))
178 .map(|(_, size)| *size)
179 .unwrap_or(DEFAULT_FILE_SIZE_LIMIT)
180}
181
182async fn upload_field_to_storage(
184 conn: &mut PgConnection,
185 path: &Path,
186 field: mp::Field,
187 file_store: &dyn FileStore,
188 uploader: Option<AuthUser>,
189) -> Result<(), ControllerError> {
190 let mime_type = field.content_type().map(|ct| ct.type_());
192 let size_limit = get_size_limit_for_mime(mime_type);
193
194 if let Some(content_disposition) = field.content_disposition()
197 && let Some(size_str) = content_disposition
198 .parameters
199 .iter()
200 .find_map(|p| p.as_unknown("size"))
201 && let Ok(size) = size_str.parse::<u64>()
202 && size > size_limit as u64
203 {
204 return Err(ControllerError::new(
205 ControllerErrorType::BadRequest,
206 format!(
207 "File size {} exceeds limit of {} bytes for type {}",
208 size,
209 size_limit,
210 mime_type.map_or("unknown".to_string(), |m| m.to_string())
211 ),
212 None,
213 ));
214 }
215
216 let mime_type = field
218 .content_type()
219 .map(|ct| ct.to_string())
220 .unwrap_or_default();
221
222 let name = {
223 let name_ref = field.name().ok_or_else(|| {
224 ControllerError::new(
225 ControllerErrorType::BadRequest,
226 "Tried to upload a file without a file name".to_string(),
227 None,
228 )
229 })?;
230 name_ref.to_string()
231 };
232
233 let contents = Box::pin(field.map_err(|orig| anyhow::Error::msg(orig.to_string())));
234
235 upload_file_to_storage(
236 conn,
237 path,
238 &name,
239 &mime_type,
240 contents,
241 file_store,
242 uploader.map(|u| u.id),
243 )
244 .await?;
245 Ok(())
246}
247pub async fn upload_certificate_svg(
248 conn: &mut PgConnection,
249 file_name: &str,
250 file: GenericPayload,
251 file_store: &dyn FileStore,
252 course_id: Uuid,
253 uploader: AuthUser,
254) -> Result<(Uuid, PathBuf), ControllerError> {
255 let path = path(file_name, FileType::Image, StoreKind::Course(course_id));
256 let safe_path = make_filename_safe(&path);
257 let id = upload_file_to_storage(
258 conn,
259 &safe_path,
260 file_name,
261 "image/svg+xml",
262 file,
263 file_store,
264 Some(uploader.id),
265 )
266 .await?;
267 Ok((id, safe_path))
268}
269
270pub struct ExerciseTaskInfo<'a> {
271 pub course_id: Uuid,
272 pub exercise: &'a Exercise,
273 pub exercise_slide: &'a ExerciseSlide,
274 pub exercise_task: &'a ExerciseTask,
275}
276
277pub async fn upload_exercise_archive(
278 conn: &mut PgConnection,
279 file: GenericPayload,
280 file_store: &dyn FileStore,
281 exercise: ExerciseTaskInfo<'_>,
282 mime: Mime,
283 uploader: Uuid,
284) -> Result<(Uuid, PathBuf), ControllerError> {
285 let file_name = &exercise.exercise.name;
286 let path = nested_path(
287 &[
288 "user-exercise-uploads",
289 "exercise",
290 &exercise.exercise.id.to_string(),
291 "slide",
292 &exercise.exercise_slide.id.to_string(),
293 "task",
294 &exercise.exercise_task.id.to_string(),
295 file_name,
296 ],
297 FileType::File,
298 StoreKind::Course(exercise.course_id),
299 );
300 let safe_path = make_filename_safe(&path);
301 let id = upload_file_to_storage(
302 conn,
303 &safe_path,
304 file_name,
305 mime.as_ref(),
306 file,
307 file_store,
308 Some(uploader),
309 )
310 .await?;
311 Ok((id, safe_path))
312}
313
314async fn upload_file_to_storage(
315 conn: &mut PgConnection,
316 path: &Path,
317 file_name: &str,
318 mime_type: &str,
319 file: GenericPayload,
320 file_store: &dyn FileStore,
321 uploader: Option<Uuid>,
322) -> Result<Uuid, ControllerError> {
323 let mut tx = conn.begin().await?;
324 let path_string = path.to_str().context("invalid path")?.to_string();
325 let id =
326 models::file_uploads::insert(&mut tx, file_name, &path_string, mime_type, uploader).await?;
327 file_store.upload_stream(path, file, mime_type).await?;
328 tx.commit().await?;
329 Ok(id)
330}
331
332fn make_filename_safe(path: &PathBuf) -> PathBuf {
333 let mut path_buf = path.to_owned();
334 let random_string = Alphanumeric.sample_string(&mut rand::rng(), 25);
335 path_buf.set_file_name(random_string);
336 if let Some(ext) = path.extension() {
337 let ext = ext
339 .to_str()
340 .unwrap_or("")
341 .chars()
342 .filter(|c| c.is_alphanumeric())
343 .collect::<String>();
344 path_buf.set_extension(ext);
345 }
346 path_buf
347}
348
349pub async fn delete_file_from_storage(
350 conn: &mut PgConnection,
351 id: Uuid,
352 file_store: &dyn FileStore,
353) -> Result<(), ControllerError> {
354 let file_to_delete = models::file_uploads::delete_and_fetch_path(conn, id).await?;
355 file_store.delete(Path::new(&file_to_delete)).await?;
356 Ok(())
357}
358
359fn generate_audio_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
361 let extension = match field
362 .content_type()
363 .map(|ct| ct.to_string())
364 .unwrap_or_default()
365 .as_str()
366 {
367 "audio/aac" => ".aac",
368 "audio/mpeg" => ".mp3",
369 "audio/ogg" => ".oga",
370 "audio/opus" => ".opus",
371 "audio/wav" => ".wav",
372 "audio/webm" => ".weba",
373 "audio/midi" => ".mid",
374 "audio/x-midi" => ".mid",
375 unsupported => {
376 return Err(ControllerError::new(
377 ControllerErrorType::BadRequest,
378 format!("Unsupported audio Mime type: {}", unsupported),
379 None,
380 ));
381 }
382 };
383 let mut file_name = generate_random_string(30);
384 file_name.push_str(extension);
385 let path = path(&file_name, FileType::Audio, store_kind);
386 Ok(path)
387}
388
389fn generate_file_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
391 let field_content = field.content_disposition().ok_or_else(|| {
392 ControllerError::new(
393 ControllerErrorType::BadRequest,
394 "No content disposition in uploaded file".to_string(),
395 None,
396 )
397 })?;
398 let field_content_name = field_content.get_filename().ok_or_else(|| {
399 ControllerError::new(
400 ControllerErrorType::BadRequest,
401 "Missing file name in content-disposition",
402 None,
403 )
404 })?;
405
406 let mut file_name = generate_random_string(30);
407 let uploaded_file_extension = get_extension_from_filename(field_content_name);
408 if let Some(extension) = uploaded_file_extension {
409 file_name.push_str(format!(".{}", extension).as_str());
410 }
411
412 let path = path(&file_name, FileType::File, store_kind);
413 Ok(path)
414}
415
416fn generate_image_path(field: &Field, store_kind: StoreKind) -> Result<PathBuf, ControllerError> {
418 let extension = match field
419 .content_type()
420 .map(|ct| ct.to_string())
421 .unwrap_or_default()
422 .as_str()
423 {
424 "image/jpeg" => ".jpg",
425 "image/png" => ".png",
426 "image/svg+xml" => ".svg",
427 "image/tiff" => ".tif",
428 "image/bmp" => ".bmp",
429 "image/webp" => ".webp",
430 "image/gif" => ".gif",
431 unsupported => {
432 return Err(ControllerError::new(
433 ControllerErrorType::BadRequest,
434 format!("Unsupported image Mime type: {}", unsupported),
435 None,
436 ));
437 }
438 };
439
440 let mut file_name = generate_random_string(30);
444 file_name.push_str(extension);
445 let path = path(&file_name, FileType::Image, store_kind);
446 Ok(path)
447}
448
449async fn validate_media_headers(
451 headers: &HeaderMap,
452 user: &AuthUser,
453 conn: &mut PgConnection,
454) -> ControllerResult<()> {
455 let content_type = headers.get(header::CONTENT_TYPE).ok_or_else(|| {
456 ControllerError::new(
457 ControllerErrorType::BadRequest,
458 "Please provide a Content-Type header",
459 None,
460 )
461 })?;
462 let content_type_string = String::from_utf8_lossy(content_type.as_bytes()).to_string();
463
464 if !content_type_string.contains("multipart/form-data") {
465 return Err(ControllerError::new(
466 ControllerErrorType::BadRequest,
467 format!("Unsupported type: {}", content_type_string),
468 None,
469 ));
470 }
471
472 let content_length = headers.get(header::CONTENT_LENGTH).ok_or_else(|| {
473 ControllerError::new(
474 ControllerErrorType::BadRequest,
475 "Please provide a Content-Length in header",
476 None,
477 )
478 })?;
479 let content_length_number = String::from_utf8_lossy(content_length.as_bytes())
480 .to_string()
481 .parse::<i32>()
482 .map_err(|original_err| {
483 ControllerError::new(
484 ControllerErrorType::InternalServerError,
485 original_err.to_string(),
486 Some(original_err.into()),
487 )
488 })?;
489
490 let mime_type = headers
491 .get("X-File-Type")
492 .map(|h| h.to_str().unwrap_or("application/octet-stream"))
493 .unwrap_or("application/octet-stream")
494 .split('/')
495 .next()
496 .map(|s| match s {
497 "image" => mime::IMAGE,
498 "audio" => mime::AUDIO,
499 "video" => mime::VIDEO,
500 "application" => mime::APPLICATION,
501 _ => mime::APPLICATION,
502 });
503 let size_limit = get_size_limit_for_mime(mime_type);
504
505 if content_length_number > size_limit {
507 return Err(ControllerError::new(
508 ControllerErrorType::BadRequest,
509 format!(
510 "File size {} exceeds limit of {} bytes for type {}",
511 content_length_number,
512 size_limit,
513 mime_type.map_or("unknown".to_string(), |m| m.to_string())
514 ),
515 None,
516 ));
517 }
518
519 let token = authorize(conn, Act::Teach, Some(user.id), Res::AnyCourse).await?;
520 token.authorized_ok(())
521}
522
523enum FileType {
524 Image,
525 Audio,
526 File,
527}
528
529fn path(file_name: &str, file_type: FileType, store_kind: StoreKind) -> PathBuf {
530 nested_path(&[file_name], file_type, store_kind)
531}
532
533fn nested_path(components: &[&str], file_type: FileType, store_kind: StoreKind) -> PathBuf {
534 let (base_dir, base_id) = match store_kind {
535 StoreKind::Organization(id) => ("organization", id),
536 StoreKind::Course(id) => ("course", id),
537 StoreKind::Exam(id) => ("exam", id),
538 };
539 let file_type_subdir = match file_type {
540 FileType::Image => "images",
541 FileType::Audio => "audios",
542 FileType::File => "files",
543 };
544 [base_dir, &base_id.to_string(), file_type_subdir]
545 .iter()
546 .chain(components)
547 .collect()
548}