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