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::{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
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(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
55/// Gets all papers which have been soft-deleted.
56pub 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
70/// Fetches a paper by id.
71pub 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
97/// Searches for question papers given a query and an optional `exam` parameter.
98///
99/// # Request Query Parameters
100/// * `query`: The query string to search in the question papers (searches course name or code)
101/// * `exam` (optional): A comma-separated string of exam types to filter. Leave empty to match any exam.
102pub 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)]
147/// The request format for the OAuth endpoint
148pub struct OAuthReq {
149    code: String,
150}
151
152#[derive(Serialize)]
153/// The response format for the OAuth endpoint
154pub struct OAuthRes {
155    token: String,
156}
157
158/// Takes a Github OAuth code and returns a JWT auth token to log in a user if authorized
159///
160/// Request format - [`OAuthReq`]
161pub 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)]
179/// The response format for the user profile endpoint
180pub struct ProfileRes {
181    token: String,
182    username: String,
183}
184
185/// 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.
186pub 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)]
197/// The request format for the paper edit endpoint
198pub 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
210/// Paper edit endpoint (for admin dashboard)
211/// Takes a JSON request body. The `id` field is required.
212/// Other optional fields can be set to change that particular value in the paper.
213///
214/// Request format - [`EditReq`]
215pub async fn edit(
216    Extension(auth): Extension<Auth>,
217    State(state): HandlerState,
218    Json(body): Json<EditReq>,
219) -> HandlerReturn<AdminDashboardQP> {
220    // Edit the database entry
221    let (tx, old_filelink, new_qp) = state
222        .db
223        .edit_paper(body, &auth.username, &state.env_vars)
224        .await?;
225
226    // Copy the actual file
227    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            // Commit the transaction
241            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        // Commit the transaction
250        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)]
259/// The details for an uploaded question paper file
260pub 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
270/// 10 MiB file size limit
271const FILE_SIZE_LIMIT: usize = 10 << 20;
272#[derive(Serialize)]
273/// The status of an uploaded question paper file
274pub struct UploadStatus {
275    /// The filename
276    filename: String,
277    /// Whether the file was successfully uploaded
278    status: &'static str,
279    /// A message describing the status
280    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
301/// Uploads question papers to the server
302///
303/// Request format - Multipart form with a `file_details` field of the format [`FileDetails`]
304pub 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        // Insert the db entry
386        let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
387
388        // Create the new filelink (slug)
389        let filelink_slug = state
390            .env_vars
391            .paths
392            .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
393
394        // Update the filelink in the db
395        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            // Write the file data
404            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                    // Transaction commit failed, delete the file
410                    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            // If the write fails, rollback the transaction, else commit it.
422        } 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)]
457/// The request format for the delete endpoint
458pub struct DeleteReq {
459    id: i32,
460}
461
462/// (Soft) Deletes a given paper.
463///
464/// Request format - [`DeleteReq`]
465pub 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)]
482/// The request format for the hard delete endpoint
483pub struct HardDeleteReq {
484    ids: Vec<i32>,
485}
486
487#[derive(Serialize)]
488/// The status of a paper to be deleted
489pub 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
513/// Hard deletes papers from a list of ids.
514///
515/// Request format - [`HardDeleteReq`]
516pub 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
550/// Fetches all question papers that match one or more properties specified. `course_name` is compulsory.
551///
552/// # Request Query Parameters
553/// * `course_code`: The course code of the question paper. (required)
554/// * `year` (optional): The year of the question paper.
555/// * `course_name` (optional): The course name (exact).
556/// * `semester` (optional): The semester (autumn/spring)
557/// * `exam` (optional): The exam field (midsem/endsem/ct)
558pub 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}