iqps_backend/routing/
handlers.rs

1//! All endpoint handlers and their response types.
2//!
3//! All endpoints accept JSON or URL query parameters as the request. The response of each handler is a [`BackendResponse`] serialized as JSON and the return type of the handler function determines the schema of the data sent in the response (if successful)
4//!
5//! The request format is described
6
7use 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
32/// The return type of a handler function. T is the data type returned if the operation was a success
33type HandlerReturn<T> = Result<(StatusCode, BackendResponse<T>), AppError>;
34
35/// Healthcheck route. Returns a `Hello World.` message if healthy.
36pub async fn healthcheck() -> HandlerReturn<()> {
37    Ok(BackendResponse::ok("Hello, World.".into(), ()))
38}
39
40/// Fetches all the unapproved papers.
41pub 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
57/// Gets all papers which have been soft-deleted.
58pub 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
72/// Fetches a paper by id.
73pub 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
99/// Searches for question papers given a query and an optional `exam` parameter.
100///
101/// # Request Query Parameters
102/// * `query`: The query string to search in the question papers (searches course name or code)
103/// * `exam` (optional): A comma-separated string of exam types to filter. Leave empty to match any exam.
104pub 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)]
148/// The request format for the OAuth endpoint
149pub struct OAuthReq {
150    code: String,
151}
152
153#[derive(Serialize)]
154/// The response format for the OAuth endpoint
155pub struct OAuthRes {
156    token: String,
157}
158
159/// Takes a Github OAuth code and returns a JWT auth token to log in a user if authorized
160///
161/// Request format - [`OAuthReq`]
162pub 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)]
180/// The response format for the user profile endpoint
181pub struct ProfileRes {
182    token: String,
183    username: String,
184}
185
186/// Returns a user's profile (the JWT and username) if authorized and the token is valid. Can be used to check if the user is logged in.
187pub 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)]
198/// The request format for the paper edit endpoint
199pub 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
211/// Paper edit endpoint (for admin dashboard)
212/// Takes a JSON request body. The `id` field is required.
213/// Other optional fields can be set to change that particular value in the paper.
214///
215/// Request format - [`EditReq`]
216pub async fn edit(
217    Extension(auth): Extension<Auth>,
218    State(state): State<RouterState>,
219    Json(body): Json<EditReq>,
220) -> HandlerReturn<AdminDashboardQP> {
221    // Edit the database entry
222    let (tx, old_filelink, new_qp) = state
223        .db
224        .edit_paper(body, &auth.username, &state.env_vars)
225        .await?;
226
227    // Copy the actual file
228    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            // Commit the transaction
242            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        // Commit the transaction
251        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)]
260/// The details for an uploaded question paper file
261pub 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
271/// 10 MiB file size limit
272const FILE_SIZE_LIMIT: usize = 10 << 20;
273#[derive(Serialize)]
274/// The status of an uploaded question paper file
275pub struct UploadStatus {
276    /// The filename
277    filename: String,
278    /// Whether the file was successfully uploaded
279    status: Status,
280    /// A message describing the status
281    message: String,
282}
283
284/// Uploads question papers to the server
285///
286/// Request format - Multipart form with a `file_details` field of the format [`FileDetails`]
287pub 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        // Insert the db entry
370        let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
371
372        // Create the new filelink (slug)
373        let filelink_slug = state
374            .env_vars
375            .paths
376            .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
377
378        // Update the filelink in the db
379        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            // Write the file data
388            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                    // Transaction commit failed, delete the file
398                    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            // If the write fails, rollback the transaction, else commit it.
411        } 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)]
450/// The request format for the delete endpoint
451pub struct DeleteReq {
452    id: i32,
453}
454
455/// (Soft) Deletes a given paper.
456///
457/// Request format - [`DeleteReq`]
458pub 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)]
478/// The request format for the hard delete endpoint
479pub struct HardDeleteReq {
480    ids: Vec<i32>,
481}
482
483#[derive(Serialize)]
484/// The status of a paper to be deleted
485pub struct DeleteStatus {
486    id: i32,
487    status: Status,
488    message: String,
489}
490
491/// Hard deletes papers from a list of ids.
492///
493/// Request format - [`HardDeleteReq`]
494pub 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
539/// Fetches all question papers that match one or more properties specified. `course_name` is compulsory.
540///
541/// # Request Query Parameters
542/// * `course_code`: The course code of the question paper. (required)
543/// * `year` (optional): The year of the question paper.
544/// * `course_name` (optional): The course name (exact).
545/// * `semester` (optional): The semester (autumn/spring)
546/// * `exam` (optional): The exam field (midsem/endsem/ct)
547pub 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}