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/// Fetches a paper by id.
58pub 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
84/// Searches for question papers given a query and an optional `exam` parameter.
85///
86/// # Request Query Parameters
87/// * `query`: The query string to search in the question papers (searches course name or code)
88/// * `exam` (optional): A comma-separated string of exam types to filter. Leave empty to match any exam.
89pub 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)]
133/// The request format for the OAuth endpoint
134pub struct OAuthReq {
135    code: String,
136}
137
138#[derive(Serialize)]
139/// The response format for the OAuth endpoint
140pub struct OAuthRes {
141    token: String,
142}
143
144/// Takes a Github OAuth code and returns a JWT auth token to log in a user if authorized
145///
146/// Request format - [`OAuthReq`]
147pub 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)]
165/// The response format for the user profile endpoint
166pub struct ProfileRes {
167    token: String,
168    username: String,
169}
170
171/// 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.
172pub 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)]
183/// The request format for the paper edit endpoint
184pub 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
196/// Paper edit endpoint (for admin dashboard)
197/// Takes a JSON request body. The `id` field is required.
198/// Other optional fields can be set to change that particular value in the paper.
199///
200/// Request format - [`EditReq`]
201pub async fn edit(
202    Extension(auth): Extension<Auth>,
203    State(state): State<RouterState>,
204    Json(body): Json<EditReq>,
205) -> HandlerReturn<AdminDashboardQP> {
206    // Edit the database entry
207    let (tx, old_filelink, new_qp) = state
208        .db
209        .edit_paper(body, &auth.username, &state.env_vars)
210        .await?;
211
212    // Copy the actual file
213    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            // Commit the transaction
227            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        // Commit the transaction
236        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)]
245/// The details for an uploaded question paper file
246pub 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
256/// 10 MiB file size limit
257const FILE_SIZE_LIMIT: usize = 10 << 20;
258#[derive(Serialize)]
259/// The status of an uploaded question paper file
260pub struct UploadStatus {
261    /// The filename
262    filename: String,
263    /// Whether the file was successfully uploaded
264    status: Status,
265    /// A message describing the status
266    message: String,
267}
268
269/// Uploads question papers to the server
270///
271/// Request format - Multipart form with a `file_details` field of the format [`FileDetails`]
272pub 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        // Insert the db entry
355        let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
356
357        // Create the new filelink (slug)
358        let filelink_slug = state
359            .env_vars
360            .paths
361            .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
362
363        // Update the filelink in the db
364        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            // Write the file data
373            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                    // Transaction commit failed, delete the file
383                    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            // If the write fails, rollback the transaction, else commit it.
396        } 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)]
439/// The request format for the delete endpoint
440pub struct DeleteReq {
441    id: i32,
442}
443
444/// Deletes a given paper. Library papers cannot be deleted.
445///
446/// Request format - [`DeleteReq`]
447pub 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
466/// Fetches all question papers that match one or more properties specified. `course_name` is compulsory.
467///
468/// # Request Query Parameters
469/// * `course_code`: The course code of the question paper. (required)
470/// * `year` (optional): The year of the question paper.
471/// * `course_name` (optional): The course name (exact).
472/// * `semester` (optional): The semester (autumn/spring)
473/// * `exam` (optional): The exam field (midsem/endsem/ct)
474pub 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}