1use axum::{
8 body::Bytes,
9 extract::{Json, Multipart},
10 http::StatusCode,
11 Extension,
12};
13use color_eyre::eyre::{ContextCompat, Result};
14use http::HeaderMap;
15use serde::Serialize;
16use tokio::fs;
17
18use std::collections::HashMap;
19
20use axum::extract::{Query, State};
21use serde::Deserialize;
22
23use crate::{
24 auth::{self, Auth},
25 pathutils::PaperCategory,
26 qp::{self, AdminDashboardQP, Exam, WithUrl},
27 slack::send_slack_message,
28};
29
30use super::{AppError, BackendResponse, RouterState, Status};
31
32type HandlerReturn<T> = Result<(StatusCode, BackendResponse<T>), AppError>;
34
35pub async fn healthcheck() -> HandlerReturn<()> {
37 Ok(BackendResponse::ok("Hello, World.".into(), ()))
38}
39
40pub async fn get_unapproved(
42 State(state): State<RouterState>,
43) -> HandlerReturn<Vec<AdminDashboardQP>> {
44 let papers: Vec<AdminDashboardQP> = state.db.get_unapproved_papers().await?;
45
46 let papers = papers
47 .iter()
48 .map(|paper| paper.clone().with_url(&state.env_vars))
49 .collect::<Result<Vec<qp::AdminDashboardQP>, color_eyre::eyre::Error>>()?;
50
51 Ok(BackendResponse::ok(
52 format!("Successfully fetched {} papers.", papers.len()),
53 papers,
54 ))
55}
56
57pub async fn get_paper_details(
59 State(state): State<RouterState>,
60 Query(params): Query<HashMap<String, String>>,
61) -> HandlerReturn<AdminDashboardQP> {
62 if let Some(id) = params.get("id") {
63 if let Ok(id) = id.parse::<i32>() {
64 let paper = state.db.get_paper_by_id(id).await?;
65 let paper_with_url = paper.with_url(&state.env_vars)?;
66 Ok(BackendResponse::ok(
67 "Successfully fetched the paper.".into(),
68 paper_with_url,
69 ))
70 } else {
71 Ok(BackendResponse::error(
72 "Invalid `id` URL parameter.".into(),
73 StatusCode::BAD_REQUEST,
74 ))
75 }
76 } else {
77 Ok(BackendResponse::error(
78 "`id` URL parameter is required.".into(),
79 StatusCode::BAD_REQUEST,
80 ))
81 }
82}
83
84pub async fn search(
90 State(state): State<RouterState>,
91 Query(params): Query<HashMap<String, String>>,
92) -> HandlerReturn<Vec<qp::BaseQP>> {
93 let response = if let Some(query) = params.get("query") {
94 let exam_query_str = params
95 .get("exam")
96 .map(|value| value.to_owned())
97 .unwrap_or("".into());
98
99 if let Ok(exam_filter) = exam_query_str
100 .split(',')
101 .filter(|val| !val.trim().is_empty())
102 .map(|val| Exam::try_from(&val.to_owned()))
103 .collect::<Result<Vec<Exam>, _>>()
104 {
105 let papers = state.db.search_papers(query, exam_filter).await?;
106
107 let papers = papers
108 .iter()
109 .map(|paper| paper.clone().with_url(&state.env_vars))
110 .collect::<Result<Vec<qp::BaseQP>, color_eyre::eyre::Error>>()?;
111
112 Ok(BackendResponse::ok(
113 format!("Successfully fetched {} papers.", papers.len()),
114 papers,
115 ))
116 } else {
117 Ok(BackendResponse::error(
118 "Invalid `exam` URL parameter.".into(),
119 StatusCode::BAD_REQUEST,
120 ))
121 }
122 } else {
123 Ok(BackendResponse::error(
124 "`query` URL parameter is required.".into(),
125 StatusCode::BAD_REQUEST,
126 ))
127 };
128
129 response
130}
131
132#[derive(Deserialize)]
133pub struct OAuthReq {
135 code: String,
136}
137
138#[derive(Serialize)]
139pub struct OAuthRes {
141 token: String,
142}
143
144pub async fn oauth(
148 State(state): State<RouterState>,
149 Json(body): Json<OAuthReq>,
150) -> HandlerReturn<OAuthRes> {
151 if let Some(token) = auth::authenticate_user(&body.code, &state.env_vars).await? {
152 Ok(BackendResponse::ok(
153 "Successfully authorized the user.".into(),
154 OAuthRes { token },
155 ))
156 } else {
157 Ok(BackendResponse::error(
158 "Error: User unauthorized.".into(),
159 StatusCode::UNAUTHORIZED,
160 ))
161 }
162}
163
164#[derive(Serialize)]
165pub struct ProfileRes {
167 token: String,
168 username: String,
169}
170
171pub async fn profile(Extension(auth): Extension<Auth>) -> HandlerReturn<ProfileRes> {
173 Ok(BackendResponse::ok(
174 "Successfully authorized the user.".into(),
175 ProfileRes {
176 token: auth.jwt,
177 username: auth.username,
178 },
179 ))
180}
181
182#[derive(Deserialize)]
183pub struct EditReq {
185 pub id: i32,
186 pub course_code: Option<String>,
187 pub course_name: Option<String>,
188 pub year: Option<i32>,
189 pub semester: Option<String>,
190 pub exam: Option<String>,
191 pub note: Option<String>,
192 pub approve_status: Option<bool>,
193 pub replace: Vec<i32>,
194}
195
196pub async fn edit(
202 Extension(auth): Extension<Auth>,
203 State(state): State<RouterState>,
204 Json(body): Json<EditReq>,
205) -> HandlerReturn<AdminDashboardQP> {
206 let (tx, old_filelink, new_qp) = state
208 .db
209 .edit_paper(body, &auth.username, &state.env_vars)
210 .await?;
211
212 let old_filepath = state.env_vars.paths.get_path_from_slug(&old_filelink);
214 let new_filepath = state.env_vars.paths.get_path_from_slug(&new_qp.qp.filelink);
215
216 if old_filepath != new_filepath {
217 if let Err(e) = fs::copy(old_filepath, new_filepath).await {
218 tracing::error!("Error copying file: {}", e);
219
220 tx.rollback().await?;
221 Ok(BackendResponse::error(
222 "Error copying question paper file.".into(),
223 StatusCode::INTERNAL_SERVER_ERROR,
224 ))
225 } else {
226 tx.commit().await?;
228
229 Ok(BackendResponse::ok(
230 "Successfully updated paper details.".into(),
231 new_qp.with_url(&state.env_vars)?,
232 ))
233 }
234 } else {
235 tx.commit().await?;
237 Ok(BackendResponse::ok(
238 "Successfully updated paper details.".into(),
239 new_qp.with_url(&state.env_vars)?,
240 ))
241 }
242}
243
244#[derive(Deserialize)]
245pub struct FileDetails {
247 pub course_code: String,
248 pub course_name: String,
249 pub year: i32,
250 pub exam: String,
251 pub semester: String,
252 pub filename: String,
253 pub note: String,
254}
255
256const FILE_SIZE_LIMIT: usize = 10 << 20;
258#[derive(Serialize)]
259pub struct UploadStatus {
261 filename: String,
263 status: Status,
265 message: String,
267}
268
269pub async fn upload(
273 State(state): State<RouterState>,
274 mut multipart: Multipart,
275) -> HandlerReturn<Vec<UploadStatus>> {
276 let mut files = Vec::<(HeaderMap, Bytes)>::new();
277 let mut file_details: String = "".into();
278
279 while let Some(field) = multipart.next_field().await.unwrap() {
280 let name = field.name().unwrap().to_string();
281
282 if name == "files" {
283 files.push((field.headers().clone(), field.bytes().await?));
284 } else if name == "file_details" {
285 if file_details.is_empty() {
286 file_details = field.text().await?;
287 } else {
288 return Ok(BackendResponse::error(
289 "Error: Multiple `file_details` fields found.".into(),
290 StatusCode::BAD_REQUEST,
291 ));
292 }
293 }
294 }
295
296 let files = files;
297 let file_details: Vec<FileDetails> = serde_json::from_str(&file_details)?;
298
299 if files.len() > state.env_vars.max_upload_limit {
300 return Ok(BackendResponse::error(
301 format!(
302 "Only upto {} files can be uploaded. Found {}.",
303 state.env_vars.max_upload_limit,
304 files.len()
305 ),
306 StatusCode::BAD_REQUEST,
307 ));
308 }
309
310 if files.len() != file_details.len() {
311 return Ok(BackendResponse::error(
312 "Error: Number of files and file details array length do not match.".into(),
313 StatusCode::BAD_REQUEST,
314 ));
315 }
316
317 let files_iter = files.iter().zip(file_details.iter());
318 let mut upload_statuses = Vec::<UploadStatus>::new();
319
320 for ((file_headers, file_data), details) in files_iter {
321 let filename = details.filename.to_owned();
322
323 if file_data.len() > FILE_SIZE_LIMIT {
324 upload_statuses.push(UploadStatus {
325 filename,
326 status: Status::Error,
327 message: format!(
328 "File size too big. Only files upto {} MiB are allowed.",
329 FILE_SIZE_LIMIT >> 20
330 ),
331 });
332 continue;
333 }
334
335 if let Some(content_type) = file_headers.get("content-type") {
336 if content_type != "application/pdf" {
337 upload_statuses.push(UploadStatus {
338 filename: filename.to_owned(),
339 status: Status::Error,
340 message: "Only PDFs are supported.".into(),
341 });
342 continue;
343 }
344 } else {
345 upload_statuses.push(UploadStatus {
346 filename,
347 status: Status::Error,
348 message: "`content-type` header not found. File type could not be determined."
349 .into(),
350 });
351 continue;
352 }
353
354 let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
356
357 let filelink_slug = state
359 .env_vars
360 .paths
361 .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
362
363 if state
365 .db
366 .update_filelink(&mut tx, id, &filelink_slug)
367 .await
368 .is_ok()
369 {
370 let filepath = state.env_vars.paths.get_path_from_slug(&filelink_slug);
371
372 if fs::write(&filepath, file_data).await.is_ok() {
374 if tx.commit().await.is_ok() {
375 upload_statuses.push(UploadStatus {
376 filename,
377 status: Status::Success,
378 message: "Succesfully uploaded file.".into(),
379 });
380 continue;
381 } else {
382 fs::remove_file(filepath).await?;
384 upload_statuses.push(UploadStatus {
385 filename,
386 status: Status::Success,
387 message: "Succesfully uploaded file.".into(),
388 });
389 continue;
390 }
391 } else {
392 tx.rollback().await?;
393 }
394
395 } else {
397 tx.rollback().await?;
398
399 upload_statuses.push(UploadStatus {
400 filename,
401 status: Status::Error,
402 message: "Error updating the filelink".into(),
403 });
404 continue;
405 }
406
407 upload_statuses.push(UploadStatus {
408 filename,
409 status: Status::Error,
410 message: "THIS SHOULD NEVER HAPPEN. REPORT IMMEDIATELY. ALSO THIS WOULDN'T HAPPEN IF RUST HAD STABLE ASYNC CLOSURES.".into(),
411 });
412 }
413
414 let total_count = state.db.get_unapproved_papers_count().await?;
415 let count = upload_statuses.len();
416 let message = format!(
417 "🔔 {} uploaded to IQPS!\n\n<https://qp.metakgp.org/admin|Review> | Total Unapproved papers: *{}*",
418 if count == 1 {
419 "A new paper was".into()
420 } else {
421 format!("{} new papers were", count)
422 },
423 total_count
424 );
425
426 let _ = send_slack_message(
427 &state.env_vars.slack_webhook_url,
428 &message,
429 )
430 .await;
431
432 Ok(BackendResponse::ok(
433 format!("Successfully processed {} files", upload_statuses.len()),
434 upload_statuses,
435 ))
436}
437
438#[derive(Deserialize)]
439pub struct DeleteReq {
441 id: i32,
442}
443
444pub async fn delete(
448 State(state): State<RouterState>,
449 Json(body): Json<DeleteReq>,
450) -> HandlerReturn<()> {
451 let paper_deleted = state.db.soft_delete(body.id).await?;
452
453 if paper_deleted {
454 Ok(BackendResponse::ok(
455 "Succesfully deleted the paper.".into(),
456 (),
457 ))
458 } else {
459 Ok(BackendResponse::error(
460 "No paper was changed. Either the paper does not exist, is a library paper (cannot be deleted), or is already deleted.".into(),
461 StatusCode::BAD_REQUEST,
462 ))
463 }
464}
465
466pub async fn similar(
475 State(state): State<RouterState>,
476 Query(body): Query<HashMap<String, String>>,
477) -> HandlerReturn<Vec<AdminDashboardQP>> {
478 if !body.contains_key("course_code") {
479 return Ok(BackendResponse::error(
480 "Error: `course_code` is required.".into(),
481 StatusCode::BAD_REQUEST,
482 ));
483 }
484
485 let papers = state
486 .db
487 .get_similar_papers(
488 body.get("course_code")
489 .context("Expected course code to be here.")?,
490 body.get("year")
491 .map(|year| year.parse::<i32>())
492 .transpose()?,
493 body.get("semester"),
494 body.get("exam"),
495 )
496 .await?;
497
498 Ok(BackendResponse::ok(
499 format!("Found {} similar papers.", papers.len()),
500 papers
501 .iter()
502 .map(|paper| paper.to_owned().with_url(&state.env_vars))
503 .collect::<Result<Vec<AdminDashboardQP>>>()?,
504 ))
505}