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