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};
28
29use super::{AppError, BackendResponse, RouterState, Status};
30
31/// The return type of a handler function. T is the data type returned if the operation was a success
32type HandlerReturn<T> = Result<(StatusCode, BackendResponse<T>), AppError>;
33
34/// Healthcheck route. Returns a `Hello World.` message if healthy.
35pub async fn healthcheck() -> HandlerReturn<()> {
36    Ok(BackendResponse::ok("Hello, World.".into(), ()))
37}
38
39/// Fetches all the unapproved papers.
40pub async fn get_unapproved(
41    State(state): State<RouterState>,
42) -> HandlerReturn<Vec<AdminDashboardQP>> {
43    let papers: Vec<AdminDashboardQP> = state.db.get_unapproved_papers().await?;
44
45    let papers = papers
46        .iter()
47        .map(|paper| paper.clone().with_url(&state.env_vars))
48        .collect::<Result<Vec<qp::AdminDashboardQP>, color_eyre::eyre::Error>>()?;
49
50    Ok(BackendResponse::ok(
51        format!("Successfully fetched {} papers.", papers.len()),
52        papers,
53    ))
54}
55
56/// Searches for question papers given a query and an optional `exam` parameter.
57///
58/// # Request Query Parameters
59/// * `query`: The query string to search in the question papers (searches course name or code)
60/// * `exam` (optional): A comma-separated string of exam types to filter. Leave empty to match any exam.
61pub async fn search(
62    State(state): State<RouterState>,
63    Query(params): Query<HashMap<String, String>>,
64) -> HandlerReturn<Vec<qp::BaseQP>> {
65    let response = if let Some(query) = params.get("query") {
66        let exam_query_str = params
67            .get("exam")
68            .map(|value| value.to_owned())
69            .unwrap_or("".into());
70
71        if let Ok(exam_filter) = exam_query_str
72            .split(',')
73            .filter(|val| !val.trim().is_empty())
74            .map(|val| Exam::try_from(&val.to_owned()))
75            .collect::<Result<Vec<Exam>, _>>()
76        {
77            let papers = state.db.search_papers(query, exam_filter).await?;
78
79            let papers = papers
80                .iter()
81                .map(|paper| paper.clone().with_url(&state.env_vars))
82                .collect::<Result<Vec<qp::BaseQP>, color_eyre::eyre::Error>>()?;
83
84            Ok(BackendResponse::ok(
85                format!("Successfully fetched {} papers.", papers.len()),
86                papers,
87            ))
88        } else {
89            Ok(BackendResponse::error(
90                "Invalid `exam` URL parameter.".into(),
91                StatusCode::BAD_REQUEST,
92            ))
93        }
94    } else {
95        Ok(BackendResponse::error(
96            "`query` URL parameter is required.".into(),
97            StatusCode::BAD_REQUEST,
98        ))
99    };
100
101    response
102}
103
104#[derive(Deserialize)]
105/// The request format for the OAuth endpoint
106pub struct OAuthReq {
107    code: String,
108}
109
110#[derive(Serialize)]
111/// The response format for the OAuth endpoint
112pub struct OAuthRes {
113    token: String,
114}
115
116/// Takes a Github OAuth code and returns a JWT auth token to log in a user if authorized
117///
118/// Request format - [`OAuthReq`]
119pub async fn oauth(
120    State(state): State<RouterState>,
121    Json(body): Json<OAuthReq>,
122) -> HandlerReturn<OAuthRes> {
123    if let Some(token) = auth::authenticate_user(&body.code, &state.env_vars).await? {
124        Ok(BackendResponse::ok(
125            "Successfully authorized the user.".into(),
126            OAuthRes { token },
127        ))
128    } else {
129        Ok(BackendResponse::error(
130            "Error: User unauthorized.".into(),
131            StatusCode::UNAUTHORIZED,
132        ))
133    }
134}
135
136#[derive(Serialize)]
137/// The response format for the user profile endpoint
138pub struct ProfileRes {
139    token: String,
140    username: String,
141}
142
143/// 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.
144pub async fn profile(Extension(auth): Extension<Auth>) -> HandlerReturn<ProfileRes> {
145    Ok(BackendResponse::ok(
146        "Successfully authorized the user.".into(),
147        ProfileRes {
148            token: auth.jwt,
149            username: auth.username,
150        },
151    ))
152}
153
154#[derive(Deserialize)]
155/// The request format for the paper edit endpoint
156pub struct EditReq {
157    pub id: i32,
158    pub course_code: Option<String>,
159    pub course_name: Option<String>,
160    pub year: Option<i32>,
161    pub semester: Option<String>,
162    pub exam: Option<String>,
163    pub note: Option<String>,
164    pub approve_status: Option<bool>,
165}
166
167/// Paper edit endpoint (for admin dashboard)
168/// Takes a JSON request body. The `id` field is required.
169/// Other optional fields can be set to change that particular value in the paper.
170///
171/// Request format - [`EditReq`]
172pub async fn edit(
173    Extension(auth): Extension<Auth>,
174    State(state): State<RouterState>,
175    Json(body): Json<EditReq>,
176) -> HandlerReturn<AdminDashboardQP> {
177    // Edit the database entry
178    let (tx, old_filelink, new_qp) = state
179        .db
180        .edit_paper(body, &auth.username, &state.env_vars)
181        .await?;
182
183    // Copy the actual file
184    let old_filepath = state.env_vars.paths.get_path_from_slug(&old_filelink);
185    let new_filepath = state.env_vars.paths.get_path_from_slug(&new_qp.qp.filelink);
186
187    if old_filepath != new_filepath {
188        if let Err(e) = fs::copy(old_filepath, new_filepath).await {
189            tracing::error!("Error copying file: {}", e);
190
191            tx.rollback().await?;
192            Ok(BackendResponse::error(
193                "Error copying question paper file.".into(),
194                StatusCode::INTERNAL_SERVER_ERROR,
195            ))
196        } else {
197            // Commit the transaction
198            tx.commit().await?;
199
200            Ok(BackendResponse::ok(
201                "Successfully updated paper details.".into(),
202                new_qp.with_url(&state.env_vars)?,
203            ))
204        }
205    } else {
206        // Commit the transaction
207        tx.commit().await?;
208        Ok(BackendResponse::ok(
209            "Successfully updated paper details.".into(),
210            new_qp.with_url(&state.env_vars)?,
211        ))
212    }
213}
214
215#[derive(Deserialize)]
216/// The details for an uploaded question paper file
217pub struct FileDetails {
218    pub course_code: String,
219    pub course_name: String,
220    pub year: i32,
221    pub exam: String,
222    pub semester: String,
223    pub filename: String,
224    pub note: String,
225}
226
227/// 10 MiB file size limit
228const FILE_SIZE_LIMIT: usize = 10 << 20;
229#[derive(Serialize)]
230/// The status of an uploaded question paper file
231pub struct UploadStatus {
232    /// The filename
233    filename: String,
234    /// Whether the file was successfully uploaded
235    status: Status,
236    /// A message describing the status
237    message: String,
238}
239
240/// Uploads question papers to the server
241///
242/// Request format - Multipart form with a `file_details` field of the format [`FileDetails`]
243pub async fn upload(
244    State(state): State<RouterState>,
245    mut multipart: Multipart,
246) -> HandlerReturn<Vec<UploadStatus>> {
247    let mut files = Vec::<(HeaderMap, Bytes)>::new();
248    let mut file_details: String = "".into();
249
250    while let Some(field) = multipart.next_field().await.unwrap() {
251        let name = field.name().unwrap().to_string();
252
253        if name == "files" {
254            files.push((field.headers().clone(), field.bytes().await?));
255        } else if name == "file_details" {
256            if file_details.is_empty() {
257                file_details = field.text().await?;
258            } else {
259                return Ok(BackendResponse::error(
260                    "Error: Multiple `file_details` fields found.".into(),
261                    StatusCode::BAD_REQUEST,
262                ));
263            }
264        }
265    }
266
267    let files = files;
268    let file_details: Vec<FileDetails> = serde_json::from_str(&file_details)?;
269
270    if files.len() > state.env_vars.max_upload_limit {
271        return Ok(BackendResponse::error(
272            format!(
273                "Only upto {} files can be uploaded. Found {}.",
274                state.env_vars.max_upload_limit,
275                files.len()
276            ),
277            StatusCode::BAD_REQUEST,
278        ));
279    }
280
281    if files.len() != file_details.len() {
282        return Ok(BackendResponse::error(
283            "Error: Number of files and file details array length do not match.".into(),
284            StatusCode::BAD_REQUEST,
285        ));
286    }
287
288    let files_iter = files.iter().zip(file_details.iter());
289    let mut upload_statuses = Vec::<UploadStatus>::new();
290
291    for ((file_headers, file_data), details) in files_iter {
292        let filename = details.filename.to_owned();
293
294        if file_data.len() > FILE_SIZE_LIMIT {
295            upload_statuses.push(UploadStatus {
296                filename,
297                status: Status::Error,
298                message: format!(
299                    "File size too big. Only files upto {} MiB are allowed.",
300                    FILE_SIZE_LIMIT >> 20
301                ),
302            });
303            continue;
304        }
305
306        if let Some(content_type) = file_headers.get("content-type") {
307            if content_type != "application/pdf" {
308                upload_statuses.push(UploadStatus {
309                    filename: filename.to_owned(),
310                    status: Status::Error,
311                    message: "Only PDFs are supported.".into(),
312                });
313                continue;
314            }
315        } else {
316            upload_statuses.push(UploadStatus {
317                filename,
318                status: Status::Error,
319                message: "`content-type` header not found. File type could not be determined."
320                    .into(),
321            });
322            continue;
323        }
324
325        // Insert the db entry
326        let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
327
328        // Create the new filelink (slug)
329        let filelink_slug = state
330            .env_vars
331            .paths
332            .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
333
334        // Update the filelink in the db
335        if state
336            .db
337            .update_filelink(&mut tx, id, &filelink_slug)
338            .await
339            .is_ok()
340        {
341            let filepath = state.env_vars.paths.get_path_from_slug(&filelink_slug);
342
343            // Write the file data
344            if fs::write(&filepath, file_data).await.is_ok() {
345                if tx.commit().await.is_ok() {
346                    upload_statuses.push(UploadStatus {
347                        filename,
348                        status: Status::Success,
349                        message: "Succesfully uploaded file.".into(),
350                    });
351                    continue;
352                } else {
353                    // Transaction commit failed, delete the file
354                    fs::remove_file(filepath).await?;
355                    upload_statuses.push(UploadStatus {
356                        filename,
357                        status: Status::Success,
358                        message: "Succesfully uploaded file.".into(),
359                    });
360                    continue;
361                }
362            } else {
363                tx.rollback().await?;
364            }
365
366            // If the write fails, rollback the transaction, else commit it.
367        } else {
368            tx.rollback().await?;
369
370            upload_statuses.push(UploadStatus {
371                filename,
372                status: Status::Error,
373                message: "Error updating the filelink".into(),
374            });
375            continue;
376        }
377
378        upload_statuses.push(UploadStatus {
379            filename,
380            status: Status::Error,
381            message: "THIS SHOULD NEVER HAPPEN. REPORT IMMEDIATELY. ALSO THIS WOULDN'T HAPPEN IF RUST HAD STABLE ASYNC CLOSURES.".into(),
382        });
383    }
384
385    Ok(BackendResponse::ok(
386        format!("Successfully processed {} files", upload_statuses.len()),
387        upload_statuses,
388    ))
389}
390
391#[derive(Deserialize)]
392/// The request format for the delete endpoint
393pub struct DeleteReq {
394    id: i32,
395}
396
397/// Deletes a given paper. Library papers cannot be deleted.
398///
399/// Request format - [`DeleteReq`]
400pub async fn delete(
401    State(state): State<RouterState>,
402    Json(body): Json<DeleteReq>,
403) -> HandlerReturn<()> {
404    let paper_deleted = state.db.soft_delete(body.id).await?;
405
406    if paper_deleted {
407        Ok(BackendResponse::ok(
408            "Succesfully deleted the paper.".into(),
409            (),
410        ))
411    } else {
412        Ok(BackendResponse::error(
413            "No paper was changed. Either the paper does not exist, is a library paper (cannot be deleted), or is already deleted.".into(),
414            StatusCode::BAD_REQUEST,
415        ))
416    }
417}
418
419/// Fetches all question papers that match one or more properties specified. `course_name` is compulsory.
420///
421/// # Request Query Parameters
422/// * `course_code`: The course code of the question paper. (required)
423/// * `year` (optional): The year of the question paper.
424/// * `course_name` (optional): The course name (exact).
425/// * `semester` (optional): The semester (autumn/spring)
426/// * `exam` (optional): The exam field (midsem/endsem/ct)
427pub async fn similar(
428    State(state): State<RouterState>,
429    Query(body): Query<HashMap<String, String>>,
430) -> HandlerReturn<Vec<AdminDashboardQP>> {
431    if !body.contains_key("course_code") {
432        return Ok(BackendResponse::error(
433            "Error: `course_code` is required.".into(),
434            StatusCode::BAD_REQUEST,
435        ));
436    }
437
438    let papers = state
439        .db
440        .get_similar_papers(
441            body.get("course_code")
442                .context("Expected course code to be here.")?,
443            body.get("year")
444                .map(|year| year.parse::<i32>())
445                .transpose()?,
446            body.get("semester"),
447            body.get("exam"),
448        )
449        .await?;
450
451    Ok(BackendResponse::ok(
452        format!("Found {} similar papers.", papers.len()),
453        papers
454            .iter()
455            .map(|paper| paper.to_owned().with_url(&state.env_vars))
456            .collect::<Result<Vec<AdminDashboardQP>>>()?,
457    ))
458}