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::{collections::HashMap, path::Path};
10use tokio::fs::read;
11use utoipa::OpenApi;
12
13#[derive(OpenApi)]
14#[openapi(paths(upload_from_exercise_service))]
15pub(crate) struct FilesApiDoc;
16#[instrument(skip(file_store))]
44#[allow(clippy::async_yields_async)]
45async fn redirect_to_storage_service(
46 tail: web::Path<String>,
47 file_store: web::Data<dyn FileStore>,
48) -> HttpResponse {
49 let inner = tail.into_inner();
50 let tail_path = Path::new(&inner);
51
52 match file_store.get_direct_download_url(tail_path).await {
53 Ok(url) => HttpResponse::Found()
54 .append_header(("location", url))
55 .append_header(("cache-control", "max-age=300, private"))
56 .finish(),
57 Err(e) => {
58 error!("Could not get file {:?}", e);
59 HttpResponse::NotFound()
60 .append_header(("cache-control", "max-age=300, private"))
61 .finish()
62 }
63 }
64}
65
66#[instrument(skip(req))]
79async fn serve_upload(req: HttpRequest, pool: web::Data<PgPool>) -> ControllerResult<HttpResponse> {
80 let mut conn = pool.acquire().await?;
81
82 let base_folder = Path::new("uploads");
84 let relative_path = req.match_info().query("tail");
85 let path = base_folder.join(relative_path);
86
87 let named_file = NamedFile::open(path).map_err(|_e| {
88 ControllerError::new(
89 ControllerErrorType::NotFound,
90 "File not found".to_string(),
91 None,
92 )
93 })?;
94 let path = named_file.path();
95 let contents = read(path).await.map_err(|_e| {
96 ControllerError::new(
97 ControllerErrorType::InternalServerError,
98 "Could not read file".to_string(),
99 None,
100 )
101 })?;
102
103 let extension = path.extension().map(|o| o.to_string_lossy().to_string());
104 let mut mime_type = None;
105 if let Some(ext_string) = extension {
106 mime_type = match ext_string.as_str() {
107 "jpg" => Some("image/jpg"),
108 "png" => Some("image/png"),
109 "svg" => Some("image/svg+xml"),
110 "webp" => Some("image/webp"),
111 "gif" => Some("image/gif"),
112 _ => None,
113 };
114 }
115 let mut response = HttpResponse::Ok();
116 if let Some(m) = mime_type {
117 response.append_header(("content-type", m));
118 }
119 if let Some(filename) = models::file_uploads::get_filename(&mut conn, relative_path)
120 .await
121 .optional()?
122 {
123 response.append_header(("Content-Disposition", format!("filename=\"{}\"", filename)));
124 }
125
126 let token = skip_authorize();
128 token.authorized_ok(response.body(contents))
129}
130
131#[instrument(skip(payload, file_store, app_conf, upload_claim))]
139#[utoipa::path(
140 post,
141 path = "/{exercise_service_slug}",
142 operation_id = "uploadFilesFromExerciseService",
143 tag = "files",
144 params(
145 ("exercise_service_slug" = String, Path, description = "Exercise service slug")
146 ),
147 request_body(
148 content = String,
149 content_type = "multipart/form-data"
150 ),
151 responses(
152 (status = 200, description = "Uploaded files", body = HashMap<String, String>)
153 )
154)]
155
156async fn upload_from_exercise_service(
157 pool: web::Data<PgPool>,
158 exercise_service_slug: web::Path<String>,
159 payload: Multipart,
160 file_store: web::Data<dyn FileStore>,
161 user: Option<AuthUser>,
162 upload_claim: Result<UploadClaim<'static>, ControllerError>,
163 app_conf: web::Data<ApplicationConfiguration>,
164) -> ControllerResult<web::Json<HashMap<String, String>>> {
165 let mut conn = pool.acquire().await?;
166 let token = skip_authorize();
169
170 if exercise_service_slug.as_str() != "playground" {
172 match (&upload_claim, &user) {
174 (Ok(upload_claim), _) => {
175 if upload_claim.exercise_service_slug() != exercise_service_slug.as_ref() {
176 return Err(ControllerError::new(
178 ControllerErrorType::BadRequest,
179 "Exercise service slug did not match upload claim".to_string(),
180 None,
181 ));
182 }
183 }
184 (_, Some(_user)) => {
185 }
187 (Err(_), None) => {
188 return Err(ControllerError::new(
189 ControllerErrorType::BadRequest,
190 "Not logged in or missing upload claim".to_string(),
191 None,
192 ));
193 }
194 }
195 }
196
197 let mut paths = HashMap::new();
198 if let Err(outer_err) = file_uploading::process_exercise_service_upload(
199 &mut conn,
200 exercise_service_slug.as_str(),
201 payload,
202 file_store.as_ref(),
203 &mut paths,
204 user,
205 &app_conf.base_url,
206 )
207 .await
208 {
209 for path in paths.values() {
211 if let Err(err) = file_store.delete(Path::new(path)).await {
212 error!("Failed to delete file '{path}' during cleanup: {err}")
213 }
214 }
215 return Err(outer_err);
216 }
217
218 token.authorized_ok(web::Json(paths))
219}
220
221pub fn _add_routes(cfg: &mut ServiceConfig) {
229 cfg.route("/uploads/{tail:.*}", web::get().to(serve_upload))
230 .route(
231 "/{exercise_service_slug}",
232 web::post().to(upload_from_exercise_service),
233 )
234 .route("{tail:.*}", web::get().to(redirect_to_storage_service));
235}