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_trash(State(state): State<RouterState>) -> HandlerReturn<Vec<AdminDashboardQP>> {
59 let papers: Vec<AdminDashboardQP> = state.db.get_soft_deleted_papers().await?;
60
61 let papers = papers
62 .iter()
63 .map(|paper| paper.clone().with_url(&state.env_vars))
64 .collect::<Result<Vec<qp::AdminDashboardQP>, color_eyre::eyre::Error>>()?;
65
66 Ok(BackendResponse::ok(
67 format!("Successfully fetched {} papers.", papers.len()),
68 papers,
69 ))
70}
71
72pub async fn get_paper_details(
74 State(state): State<RouterState>,
75 Query(params): Query<HashMap<String, String>>,
76) -> HandlerReturn<AdminDashboardQP> {
77 if let Some(id) = params.get("id") {
78 if let Ok(id) = id.parse::<i32>() {
79 let paper = state.db.get_paper_by_id(id).await?;
80 let paper_with_url = paper.with_url(&state.env_vars)?;
81 Ok(BackendResponse::ok(
82 "Successfully fetched the paper.".into(),
83 paper_with_url,
84 ))
85 } else {
86 Ok(BackendResponse::error(
87 "Invalid `id` URL parameter.".into(),
88 StatusCode::BAD_REQUEST,
89 ))
90 }
91 } else {
92 Ok(BackendResponse::error(
93 "`id` URL parameter is required.".into(),
94 StatusCode::BAD_REQUEST,
95 ))
96 }
97}
98
99pub async fn search(
105 State(state): State<RouterState>,
106 Query(params): Query<HashMap<String, String>>,
107) -> HandlerReturn<Vec<qp::BaseQP>> {
108 let response = if let Some(query) = params.get("query") {
109 let exam_query_str = params
110 .get("exam")
111 .map(|value| value.to_owned())
112 .unwrap_or("".into());
113
114 if let Ok(exam_filter) = exam_query_str
115 .split(',')
116 .filter(|val| !val.trim().is_empty())
117 .map(|val| Exam::try_from(&val.to_owned()))
118 .collect::<Result<Vec<Exam>, _>>()
119 {
120 let papers = state.db.search_papers(query, exam_filter).await?;
121
122 let papers = papers
123 .iter()
124 .map(|paper| paper.clone().with_url(&state.env_vars))
125 .collect::<Result<Vec<qp::BaseQP>, color_eyre::eyre::Error>>()?;
126
127 Ok(BackendResponse::ok(
128 format!("Successfully fetched {} papers.", papers.len()),
129 papers,
130 ))
131 } else {
132 Ok(BackendResponse::error(
133 "Invalid `exam` URL parameter.".into(),
134 StatusCode::BAD_REQUEST,
135 ))
136 }
137 } else {
138 Ok(BackendResponse::error(
139 "`query` URL parameter is required.".into(),
140 StatusCode::BAD_REQUEST,
141 ))
142 };
143
144 response
145}
146
147#[derive(Deserialize)]
148pub struct OAuthReq {
150 code: String,
151}
152
153#[derive(Serialize)]
154pub struct OAuthRes {
156 token: String,
157}
158
159pub async fn oauth(
163 State(state): State<RouterState>,
164 Json(body): Json<OAuthReq>,
165) -> HandlerReturn<OAuthRes> {
166 if let Some(token) = auth::authenticate_user(&body.code, &state.env_vars).await? {
167 Ok(BackendResponse::ok(
168 "Successfully authorized the user.".into(),
169 OAuthRes { token },
170 ))
171 } else {
172 Ok(BackendResponse::error(
173 "Error: User unauthorized.".into(),
174 StatusCode::UNAUTHORIZED,
175 ))
176 }
177}
178
179#[derive(Serialize)]
180pub struct ProfileRes {
182 token: String,
183 username: String,
184}
185
186pub async fn profile(Extension(auth): Extension<Auth>) -> HandlerReturn<ProfileRes> {
188 Ok(BackendResponse::ok(
189 "Successfully authorized the user.".into(),
190 ProfileRes {
191 token: auth.jwt,
192 username: auth.username,
193 },
194 ))
195}
196
197#[derive(Deserialize)]
198pub struct EditReq {
200 pub id: i32,
201 pub course_code: Option<String>,
202 pub course_name: Option<String>,
203 pub year: Option<i32>,
204 pub semester: Option<String>,
205 pub exam: Option<String>,
206 pub note: Option<String>,
207 pub approve_status: Option<bool>,
208 pub replace: Vec<i32>,
209}
210
211pub async fn edit(
217 Extension(auth): Extension<Auth>,
218 State(state): State<RouterState>,
219 Json(body): Json<EditReq>,
220) -> HandlerReturn<AdminDashboardQP> {
221 let (tx, old_filelink, new_qp) = state
223 .db
224 .edit_paper(body, &auth.username, &state.env_vars)
225 .await?;
226
227 let old_filepath = state.env_vars.paths.get_path_from_slug(&old_filelink);
229 let new_filepath = state.env_vars.paths.get_path_from_slug(&new_qp.qp.filelink);
230
231 if old_filepath != new_filepath {
232 if let Err(e) = fs::copy(old_filepath, new_filepath).await {
233 tracing::error!("Error copying file: {}", e);
234
235 tx.rollback().await?;
236 Ok(BackendResponse::error(
237 "Error copying question paper file.".into(),
238 StatusCode::INTERNAL_SERVER_ERROR,
239 ))
240 } else {
241 tx.commit().await?;
243
244 Ok(BackendResponse::ok(
245 "Successfully updated paper details.".into(),
246 new_qp.with_url(&state.env_vars)?,
247 ))
248 }
249 } else {
250 tx.commit().await?;
252 Ok(BackendResponse::ok(
253 "Successfully updated paper details.".into(),
254 new_qp.with_url(&state.env_vars)?,
255 ))
256 }
257}
258
259#[derive(Deserialize)]
260pub struct FileDetails {
262 pub course_code: String,
263 pub course_name: String,
264 pub year: i32,
265 pub exam: String,
266 pub semester: String,
267 pub filename: String,
268 pub note: String,
269}
270
271const FILE_SIZE_LIMIT: usize = 10 << 20;
273#[derive(Serialize)]
274pub struct UploadStatus {
276 filename: String,
278 status: Status,
280 message: String,
282}
283
284pub async fn upload(
288 State(state): State<RouterState>,
289 mut multipart: Multipart,
290) -> HandlerReturn<Vec<UploadStatus>> {
291 let mut files = Vec::<(HeaderMap, Bytes)>::new();
292 let mut file_details: String = "".into();
293
294 while let Some(field) = multipart.next_field().await.unwrap() {
295 let name = field.name().unwrap().to_string();
296
297 if name == "files" {
298 files.push((field.headers().clone(), field.bytes().await?));
299 } else if name == "file_details" {
300 if file_details.is_empty() {
301 file_details = field.text().await?;
302 } else {
303 return Ok(BackendResponse::error(
304 "Error: Multiple `file_details` fields found.".into(),
305 StatusCode::BAD_REQUEST,
306 ));
307 }
308 }
309 }
310
311 let files = files;
312 let file_details: Vec<FileDetails> = serde_json::from_str(&file_details)?;
313
314 if files.len() > state.env_vars.max_upload_limit {
315 return Ok(BackendResponse::error(
316 format!(
317 "Only upto {} files can be uploaded. Found {}.",
318 state.env_vars.max_upload_limit,
319 files.len()
320 ),
321 StatusCode::BAD_REQUEST,
322 ));
323 }
324
325 if files.len() != file_details.len() {
326 return Ok(BackendResponse::error(
327 "Error: Number of files and file details array length do not match.".into(),
328 StatusCode::BAD_REQUEST,
329 ));
330 }
331
332 let files_iter = files.iter().zip(file_details.iter());
333 let mut upload_statuses = Vec::<UploadStatus>::new();
334
335 for ((file_headers, file_data), details) in files_iter {
336 let filename = details.filename.to_owned();
337
338 if file_data.len() > FILE_SIZE_LIMIT {
339 upload_statuses.push(UploadStatus {
340 filename,
341 status: Status::Error,
342 message: format!(
343 "File size too big. Only files upto {} MiB are allowed.",
344 FILE_SIZE_LIMIT >> 20
345 ),
346 });
347 continue;
348 }
349
350 if let Some(content_type) = file_headers.get("content-type") {
351 if content_type != "application/pdf" {
352 upload_statuses.push(UploadStatus {
353 filename: filename.to_owned(),
354 status: Status::Error,
355 message: "Only PDFs are supported.".into(),
356 });
357 continue;
358 }
359 } else {
360 upload_statuses.push(UploadStatus {
361 filename,
362 status: Status::Error,
363 message: "`content-type` header not found. File type could not be determined."
364 .into(),
365 });
366 continue;
367 }
368
369 let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
371
372 let filelink_slug = state
374 .env_vars
375 .paths
376 .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
377
378 if state
380 .db
381 .update_filelink(&mut tx, id, &filelink_slug)
382 .await
383 .is_ok()
384 {
385 let filepath = state.env_vars.paths.get_path_from_slug(&filelink_slug);
386
387 if fs::write(&filepath, file_data).await.is_ok() {
389 if tx.commit().await.is_ok() {
390 upload_statuses.push(UploadStatus {
391 filename,
392 status: Status::Success,
393 message: "Succesfully uploaded file.".into(),
394 });
395 continue;
396 } else {
397 fs::remove_file(filepath).await?;
399 upload_statuses.push(UploadStatus {
400 filename,
401 status: Status::Success,
402 message: "Succesfully uploaded file.".into(),
403 });
404 continue;
405 }
406 } else {
407 tx.rollback().await?;
408 }
409
410 } else {
412 tx.rollback().await?;
413
414 upload_statuses.push(UploadStatus {
415 filename,
416 status: Status::Error,
417 message: "Error updating the filelink".into(),
418 });
419 continue;
420 }
421
422 upload_statuses.push(UploadStatus {
423 filename,
424 status: Status::Error,
425 message: "THIS SHOULD NEVER HAPPEN. REPORT IMMEDIATELY. ALSO THIS WOULDN'T HAPPEN IF RUST HAD STABLE ASYNC CLOSURES.".into(),
426 });
427 }
428
429 let total_count = state.db.get_unapproved_papers_count().await?;
430 let count = upload_statuses.len();
431 let message = format!(
432 "🔔 {} uploaded to IQPS!\n\n<https://qp.metakgp.org/admin|Review> | Total Unapproved papers: *{}*",
433 if count == 1 {
434 "A new paper was".into()
435 } else {
436 format!("{} new papers were", count)
437 },
438 total_count
439 );
440
441 let _ = send_slack_message(&state.env_vars.slack_webhook_url, &message).await;
442
443 Ok(BackendResponse::ok(
444 format!("Successfully processed {} files", upload_statuses.len()),
445 upload_statuses,
446 ))
447}
448
449#[derive(Deserialize)]
450pub struct DeleteReq {
452 id: i32,
453}
454
455pub async fn delete(
459 State(state): State<RouterState>,
460 Json(body): Json<DeleteReq>,
461) -> HandlerReturn<()> {
462 let paper_deleted = state.db.soft_delete(body.id).await?;
463
464 if paper_deleted {
465 Ok(BackendResponse::ok(
466 "Succesfully deleted the paper.".into(),
467 (),
468 ))
469 } else {
470 Ok(BackendResponse::error(
471 "No paper was changed. Either the paper does not exist, or is already deleted.".into(),
472 StatusCode::BAD_REQUEST,
473 ))
474 }
475}
476
477#[derive(Deserialize)]
478pub struct HardDeleteReq {
480 ids: Vec<i32>,
481}
482
483#[derive(Serialize)]
484pub struct DeleteStatus {
486 id: i32,
487 status: Status,
488 message: String,
489}
490
491pub async fn hard_delete(
495 State(state): State<RouterState>,
496 Json(body): Json<HardDeleteReq>,
497) -> HandlerReturn<Vec<DeleteStatus>> {
498 let mut delete_statuses = Vec::<DeleteStatus>::new();
499 let mut deleted_count = 0;
500 for id in body.ids {
501 if let Ok(paper) = state.db.get_paper_by_id(id).await {
502 let tx = state.db.hard_delete(id).await?;
503 let filepath = state.env_vars.paths.get_path_from_slug(&paper.qp.filelink);
504 if fs::remove_file(&filepath).await.is_ok() {
505 if tx.commit().await.is_ok() {
506 delete_statuses.push(DeleteStatus {
507 id,
508 status: Status::Success,
509 message: "Successfully hard deleted the paper.".into(),
510 });
511 deleted_count += 1;
512 } else {
513 delete_statuses.push(DeleteStatus {
514 id,
515 status: Status::Error,
516 message: "Error committing the transaction.".into(),
517 });
518 }
519 } else {
520 tx.rollback().await?;
521 delete_statuses.push(DeleteStatus {
522 id,
523 status: Status::Error,
524 message: "Failed to delete file.".into(),
525 });
526 }
527 }
528 }
529
530 let message = if deleted_count > 0 {
531 format!("Successfully hard deleted {} papers.", deleted_count)
532 } else {
533 "No papers were deleted.".into()
534 };
535
536 Ok(BackendResponse::ok(message, delete_statuses))
537}
538
539pub async fn similar(
548 State(state): State<RouterState>,
549 Query(body): Query<HashMap<String, String>>,
550) -> HandlerReturn<Vec<AdminDashboardQP>> {
551 if !body.contains_key("course_code") {
552 return Ok(BackendResponse::error(
553 "Error: `course_code` is required.".into(),
554 StatusCode::BAD_REQUEST,
555 ));
556 }
557
558 let papers = state
559 .db
560 .get_similar_papers(
561 body.get("course_code")
562 .context("Expected course code to be here.")?,
563 body.get("year")
564 .map(|year| year.parse::<i32>())
565 .transpose()?,
566 body.get("semester"),
567 body.get("exam"),
568 )
569 .await?;
570
571 Ok(BackendResponse::ok(
572 format!("Found {} similar papers.", papers.len()),
573 papers
574 .iter()
575 .map(|paper| paper.to_owned().with_url(&state.env_vars))
576 .collect::<Result<Vec<AdminDashboardQP>>>()?,
577 ))
578}