From 7f61933184597c35e60bfc0227a0278f5e3eefb7 Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Fri, 11 Oct 2024 18:09:31 +0530 Subject: [PATCH 01/33] feat: updated backend response for profile endpoint --- frontend/src/types/backend.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/types/backend.ts b/frontend/src/types/backend.ts index 0e0c82f..d63a9ef 100644 --- a/frontend/src/types/backend.ts +++ b/frontend/src/types/backend.ts @@ -62,6 +62,7 @@ export interface IEndpointTypes { request: null; response: { username: string; + token: string; } }, similar: { From e5fc090070411350b48a93f409663495032fb082 Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Fri, 11 Oct 2024 18:34:04 +0530 Subject: [PATCH 02/33] feat: added db fn to get paper by id --- backend-rs/src/db.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend-rs/src/db.rs b/backend-rs/src/db.rs index 120d305..b467ec4 100644 --- a/backend-rs/src/db.rs +++ b/backend-rs/src/db.rs @@ -66,6 +66,14 @@ impl Database { .map(|qp| qp::SearchQP::from(qp.clone())) .collect()) } + + pub async fn get_paper_by_id(&self, id: i32) -> Result { + let query = sqlx::query_as(queries::GET_PAPER_BY_ID).bind(id); + + let paper: models::DBAdminDashboardQP = query.fetch_one(&self.connection).await?; + + Ok(paper.into()) + } } mod models { @@ -134,6 +142,9 @@ mod models { } mod queries { + /// Get a paper ([`crate::db::models::DBAdminDashboardQP`]) with the given id (first parameter `$1`) + pub const GET_PAPER_BY_ID: &str = "SELECT id, filelink, from_library, course_code, course_name, year, semester, exam, upload_timestamp, approve_status FROM iqps WHERE id = $1"; + /// Gets all unapproved papers ([`crate::db::models::DBAdminDashboardQP`]) from the database pub const GET_ALL_UNAPPROVED: &str = "SELECT id, filelink, from_library, course_code, course_name, year, semester, exam, upload_timestamp, approve_status FROM iqps WHERE approve_status = false and is_deleted=false ORDER BY upload_timestamp ASC"; From b5e29fdf5c23bc44407c629a71fb03c6a797daa3 Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Fri, 11 Oct 2024 21:51:29 +0530 Subject: [PATCH 03/33] refactor: split routing into separate files --- backend-rs/src/routing.rs | 343 --------------------------- backend-rs/src/routing/handlers.rs | 127 ++++++++++ backend-rs/src/routing/middleware.rs | 52 ++++ backend-rs/src/routing/mod.rs | 157 ++++++++++++ 4 files changed, 336 insertions(+), 343 deletions(-) delete mode 100644 backend-rs/src/routing.rs create mode 100644 backend-rs/src/routing/handlers.rs create mode 100644 backend-rs/src/routing/middleware.rs create mode 100644 backend-rs/src/routing/mod.rs diff --git a/backend-rs/src/routing.rs b/backend-rs/src/routing.rs deleted file mode 100644 index acfafee..0000000 --- a/backend-rs/src/routing.rs +++ /dev/null @@ -1,343 +0,0 @@ -use std::sync::Arc; - -use axum::{extract::Json, http::StatusCode, response::IntoResponse}; -use http::{HeaderValue, Method}; -use jwt::Claims; -use serde::Serialize; -use tokio::sync::Mutex; -use tower_http::{ - cors::{Any, CorsLayer}, - trace::{self, TraceLayer}, -}; - -use crate::{ - db::{self, Database}, - env::EnvVars, -}; - -pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router { - let state = RouterState { - db, - env_vars: env_vars.clone(), - auth: Arc::new(Mutex::new(None)), - }; - - axum::Router::new() - .route("/unapproved", axum::routing::get(handlers::get_unapproved)) - .route("/profile", axum::routing::get(handlers::profile)) - .route_layer(axum::middleware::from_fn_with_state( - state.clone(), - middleware::verify_jwt_middleware, - )) - .route("/oauth", axum::routing::post(handlers::oauth)) - .route("/healthcheck", axum::routing::get(handlers::healthcheck)) - .route("/search", axum::routing::get(handlers::search)) - .with_state(state) - .layer( - TraceLayer::new_for_http() - .make_span_with(trace::DefaultMakeSpan::new().level(tracing::Level::INFO)) - .on_response(trace::DefaultOnResponse::new().level(tracing::Level::INFO)), - ) - .layer( - CorsLayer::new() - .allow_headers(Any) - .allow_methods(vec![Method::GET, Method::POST, Method::OPTIONS]) - .allow_origin( - env_vars - .cors_allowed_origins - .split(',') - .map(|origin| { - origin - .trim() - .parse::() - .expect("CORS Allowed Origins Invalid") - }) - .collect::>(), - ), - ) -} - -mod handlers { - use std::collections::HashMap; - - use axum::{ - extract::{Json, Query, State}, - http::StatusCode, - }; - use color_eyre::eyre::Ok; - use serde::{Deserialize, Serialize}; - - use crate::{ - auth, - qp::{self, AdminDashboardQP}, - }; - - use super::{AppError, BackendResponse, RouterState}; - - type HandlerReturn = Result<(StatusCode, BackendResponse), AppError>; - - /// Healthcheck route. Returns a `Hello World.` message if healthy. - pub async fn healthcheck() -> HandlerReturn<()> { - Ok(BackendResponse::ok("Hello, World.".into(), ())).map_err(AppError::from) - } - - pub async fn get_unapproved( - State(state): State, - ) -> HandlerReturn> { - let papers: Vec = state.db.get_unapproved_papers().await?; - - let papers = papers - .iter() - .map(|paper| paper.clone().with_url(&state.env_vars)) - .collect::, color_eyre::eyre::Error>>()?; - - Ok(BackendResponse::ok( - format!("Successfully fetched {} papers.", papers.len()), - papers, - )) - .map_err(AppError::from) - } - - pub async fn search( - State(state): State, - Query(params): Query>, - ) -> HandlerReturn> { - let response = if let Some(query) = params.get("query") { - let exam: Option = if let Some(exam_str) = params.get("exam") { - Some(qp::Exam::try_from(exam_str).map_err(AppError::from)?) - } else { - None - }; - - let papers = state.db.search_papers(query, exam).await?; - - let papers = papers - .iter() - .map(|paper| paper.clone().with_url(&state.env_vars)) - .collect::, color_eyre::eyre::Error>>()?; - - Ok(BackendResponse::ok( - format!("Successfully fetched {} papers.", papers.len()), - papers, - )) - } else { - Ok(BackendResponse::error( - "`query` URL parameter is required.".into(), - StatusCode::BAD_REQUEST, - )) - }; - - response.map_err(AppError::from) - } - - #[derive(Deserialize)] - pub struct OAuthReq { - code: String, - } - #[derive(Serialize)] - pub struct OAuthRes { - token: String, - } - pub async fn oauth( - State(state): State, - Json(body): Json, - ) -> HandlerReturn { - if let Some(token) = auth::authenticate_user(&body.code, &state.env_vars).await? { - Ok(BackendResponse::ok( - "Successfully authorized the user.".into(), - OAuthRes { token }, - )) - } else { - Ok(BackendResponse::error( - "Error: User unauthorized.".into(), - StatusCode::UNAUTHORIZED, - )) - } - .map_err(AppError::from) - } - - #[derive(Serialize)] - pub struct ProfileRes { - token: String, - username: String, - } - pub async fn profile(State(state): State) -> HandlerReturn { - let lock = state.auth.lock().await; - - if let Some(auth) = lock.clone() { - let username = auth.claims.private.get("username"); - - if let Some(username) = username { - Ok(BackendResponse::ok( - "Successfully authorized the user.".into(), - ProfileRes { - token: auth.jwt, - username: username.to_string(), - }, - )) - } else { - Ok(BackendResponse::error( - "Username not found in claims.".into(), - StatusCode::UNAUTHORIZED, - )) - } - } else { - Ok(BackendResponse::error( - "Error: User unauthorized.".into(), - StatusCode::UNAUTHORIZED, - )) - } - .map_err(AppError::from) - } -} - -mod middleware { - use axum::{ - extract::{Request, State}, - middleware::Next, - response::{IntoResponse, Response}, - }; - use http::{HeaderMap, StatusCode}; - - use crate::auth; - - use super::{AppError, Auth, BackendResponse, RouterState}; - - pub async fn verify_jwt_middleware( - State(state): State, - headers: HeaderMap, - request: Request, - next: Next, - ) -> Result { - if let Some(auth_header) = headers.get("Authorization") { - if let Some(jwt) = auth_header.to_str()?.strip_prefix("Bearer ") { - let claims = auth::verify_token(jwt, &state.env_vars).await?; - - if let Some(claims) = claims { - let mut state_jwt = state.auth.lock().await; - *state_jwt = Auth { - jwt: jwt.to_string(), - claims, - } - .into(); - } else { - return Ok(BackendResponse::<()>::error( - "Authorization token invalid.".into(), - StatusCode::UNAUTHORIZED, - ) - .into_response()); - } - } else { - return Ok(BackendResponse::<()>::error( - "Authorization header format invalid.".into(), - StatusCode::UNAUTHORIZED, - ) - .into_response()); - } - } else { - return Ok(BackendResponse::<()>::error( - "Authorization header missing.".into(), - StatusCode::UNAUTHORIZED, - ) - .into_response()); - } - - Ok(next.run(request).await) - } -} - -#[derive(Clone)] -struct Auth { - jwt: String, - claims: Claims, -} -#[derive(Clone)] -struct RouterState { - pub db: db::Database, - pub env_vars: EnvVars, - pub auth: Arc>>, -} - -#[derive(Clone, Copy)] -enum Status { - Success, - Error, -} - -impl From for String { - fn from(value: Status) -> Self { - match value { - Status::Success => "success".into(), - Status::Error => "error".into(), - } - } -} - -impl Serialize for Status { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&String::from(*self)) - } -} - -/// Standard backend response format (serialized as JSON) -#[derive(serde::Serialize)] -struct BackendResponse { - pub status: Status, - pub message: String, - pub data: Option, -} - -impl BackendResponse { - pub fn ok(message: String, data: T) -> (StatusCode, Self) { - ( - StatusCode::OK, - Self { - status: Status::Success, - message, - data: Some(data), - }, - ) - } - - pub fn error(message: String, status_code: StatusCode) -> (StatusCode, Self) { - ( - status_code, - Self { - status: Status::Error, - message, - data: None, - }, - ) - } -} - -impl IntoResponse for BackendResponse { - fn into_response(self) -> axum::response::Response { - Json(self).into_response() - } -} - -pub(super) struct AppError(color_eyre::eyre::Error); -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - tracing::error!("An error occured: {}", self.0); - - BackendResponse::<()>::error( - format!("Internal error: {}", self.0), - StatusCode::INTERNAL_SERVER_ERROR, - ) - .into_response() - } -} - -impl From for AppError -where - E: Into, -{ - fn from(err: E) -> Self { - Self(err.into()) - } -} diff --git a/backend-rs/src/routing/handlers.rs b/backend-rs/src/routing/handlers.rs new file mode 100644 index 0000000..2c260f3 --- /dev/null +++ b/backend-rs/src/routing/handlers.rs @@ -0,0 +1,127 @@ +use axum::{extract::Json, http::StatusCode}; +use serde::Serialize; + +use std::collections::HashMap; + +use axum::extract::{Query, State}; +use serde::Deserialize; + +use crate::{ + auth, + qp::{self, AdminDashboardQP}, +}; + +use super::{AppError, BackendResponse, RouterState}; + +type HandlerReturn = Result<(StatusCode, BackendResponse), AppError>; + +/// Healthcheck route. Returns a `Hello World.` message if healthy. +pub async fn healthcheck() -> HandlerReturn<()> { + Ok(BackendResponse::ok("Hello, World.".into(), ())) +} + +pub async fn get_unapproved( + State(state): State, +) -> HandlerReturn> { + let papers: Vec = state.db.get_unapproved_papers().await?; + + let papers = papers + .iter() + .map(|paper| paper.clone().with_url(&state.env_vars)) + .collect::, color_eyre::eyre::Error>>()?; + + Ok(BackendResponse::ok( + format!("Successfully fetched {} papers.", papers.len()), + papers, + )) +} + +pub async fn search( + State(state): State, + Query(params): Query>, +) -> HandlerReturn> { + let response = if let Some(query) = params.get("query") { + let exam: Option = if let Some(exam_str) = params.get("exam") { + Some(qp::Exam::try_from(exam_str).map_err(AppError::from)?) + } else { + None + }; + + let papers = state.db.search_papers(query, exam).await?; + + let papers = papers + .iter() + .map(|paper| paper.clone().with_url(&state.env_vars)) + .collect::, color_eyre::eyre::Error>>()?; + + Ok(BackendResponse::ok( + format!("Successfully fetched {} papers.", papers.len()), + papers, + )) + } else { + Ok(BackendResponse::error( + "`query` URL parameter is required.".into(), + StatusCode::BAD_REQUEST, + )) + }; + + response +} + +#[derive(Deserialize)] +pub struct OAuthReq { + code: String, +} +#[derive(Serialize)] +pub struct OAuthRes { + token: String, +} +pub async fn oauth( + State(state): State, + Json(body): Json, +) -> HandlerReturn { + if let Some(token) = auth::authenticate_user(&body.code, &state.env_vars).await? { + Ok(BackendResponse::ok( + "Successfully authorized the user.".into(), + OAuthRes { token }, + )) + } else { + Ok(BackendResponse::error( + "Error: User unauthorized.".into(), + StatusCode::UNAUTHORIZED, + )) + } +} + +#[derive(Serialize)] +pub struct ProfileRes { + token: String, + username: String, +} +pub async fn profile(State(state): State) -> HandlerReturn { + let lock = state.auth.lock().await; + + if let Some(auth) = lock.clone() { + let username = auth.claims.private.get("username"); + + if let Some(username) = username { + Ok(BackendResponse::ok( + "Successfully authorized the user.".into(), + ProfileRes { + token: auth.jwt, + username: username.to_string(), + }, + )) + } else { + Ok(BackendResponse::error( + "Username not found in claims.".into(), + StatusCode::UNAUTHORIZED, + )) + } + } else { + Ok(BackendResponse::error( + "Error: User unauthorized.".into(), + StatusCode::UNAUTHORIZED, + )) + } +} diff --git a/backend-rs/src/routing/middleware.rs b/backend-rs/src/routing/middleware.rs new file mode 100644 index 0000000..942cb05 --- /dev/null +++ b/backend-rs/src/routing/middleware.rs @@ -0,0 +1,52 @@ +use axum::{ + extract::{Request, State}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use http::{HeaderMap, StatusCode}; + +use crate::auth; + +use super::{AppError, Auth, BackendResponse, RouterState}; + +pub async fn verify_jwt_middleware( + State(state): State, + headers: HeaderMap, + request: Request, + next: Next, +) -> Result { + if let Some(auth_header) = headers.get("Authorization") { + if let Some(jwt) = auth_header.to_str()?.strip_prefix("Bearer ") { + let claims = auth::verify_token(jwt, &state.env_vars).await?; + + if let Some(claims) = claims { + let mut state_jwt = state.auth.lock().await; + *state_jwt = Auth { + jwt: jwt.to_string(), + claims, + } + .into(); + } else { + return Ok(BackendResponse::<()>::error( + "Authorization token invalid.".into(), + StatusCode::UNAUTHORIZED, + ) + .into_response()); + } + } else { + return Ok(BackendResponse::<()>::error( + "Authorization header format invalid.".into(), + StatusCode::UNAUTHORIZED, + ) + .into_response()); + } + } else { + return Ok(BackendResponse::<()>::error( + "Authorization header missing.".into(), + StatusCode::UNAUTHORIZED, + ) + .into_response()); + } + + Ok(next.run(request).await) +} diff --git a/backend-rs/src/routing/mod.rs b/backend-rs/src/routing/mod.rs new file mode 100644 index 0000000..c790ea7 --- /dev/null +++ b/backend-rs/src/routing/mod.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use axum::{extract::Json, http::StatusCode, response::IntoResponse}; +use http::{HeaderValue, Method}; +use jwt::Claims; +use serde::Serialize; +use tokio::sync::Mutex; +use tower_http::{ + cors::{Any, CorsLayer}, + trace::{self, TraceLayer}, +}; + +use crate::{ + db::{self, Database}, + env::EnvVars, +}; + +mod handlers; +mod middleware; + +pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router { + let state = RouterState { + db, + env_vars: env_vars.clone(), + auth: Arc::new(Mutex::new(None)), + }; + + axum::Router::new() + .route("/unapproved", axum::routing::get(handlers::get_unapproved)) + .route("/profile", axum::routing::get(handlers::profile)) + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::verify_jwt_middleware, + )) + .route("/oauth", axum::routing::post(handlers::oauth)) + .route("/healthcheck", axum::routing::get(handlers::healthcheck)) + .route("/search", axum::routing::get(handlers::search)) + .with_state(state) + .layer( + TraceLayer::new_for_http() + .make_span_with(trace::DefaultMakeSpan::new().level(tracing::Level::INFO)) + .on_response(trace::DefaultOnResponse::new().level(tracing::Level::INFO)), + ) + .layer( + CorsLayer::new() + .allow_headers(Any) + .allow_methods(vec![Method::GET, Method::POST, Method::OPTIONS]) + .allow_origin( + env_vars + .cors_allowed_origins + .split(',') + .map(|origin| { + origin + .trim() + .parse::() + .expect("CORS Allowed Origins Invalid") + }) + .collect::>(), + ), + ) +} + +#[derive(Clone)] +struct Auth { + jwt: String, + claims: Claims, +} +#[derive(Clone)] +struct RouterState { + pub db: db::Database, + pub env_vars: EnvVars, + pub auth: Arc>>, +} + +#[derive(Clone, Copy)] +enum Status { + Success, + Error, +} + +impl From for String { + fn from(value: Status) -> Self { + match value { + Status::Success => "success".into(), + Status::Error => "error".into(), + } + } +} + +impl Serialize for Status { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&String::from(*self)) + } +} + +/// Standard backend response format (serialized as JSON) +#[derive(serde::Serialize)] +struct BackendResponse { + pub status: Status, + pub message: String, + pub data: Option, +} + +impl BackendResponse { + pub fn ok(message: String, data: T) -> (StatusCode, Self) { + ( + StatusCode::OK, + Self { + status: Status::Success, + message, + data: Some(data), + }, + ) + } + + pub fn error(message: String, status_code: StatusCode) -> (StatusCode, Self) { + ( + status_code, + Self { + status: Status::Error, + message, + data: None, + }, + ) + } +} + +impl IntoResponse for BackendResponse { + fn into_response(self) -> axum::response::Response { + Json(self).into_response() + } +} + +pub(super) struct AppError(color_eyre::eyre::Error); +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + tracing::error!("An error occured: {}", self.0); + + BackendResponse::<()>::error( + format!("Internal error: {}", self.0), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response() + } +} + +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} From cc2b493b634d962792eb4b43a63bd4f0aed7403f Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Sat, 12 Oct 2024 01:14:37 +0530 Subject: [PATCH 04/33] feat: edit paper details endpoint --- backend-rs/src/db.rs | 103 +++++++++++++++++++++++++- backend-rs/src/env.rs | 25 ++++++- backend-rs/src/qp.rs | 10 ++- backend-rs/src/routing/handlers.rs | 107 ++++++++++++++++++++++++--- backend-rs/src/routing/middleware.rs | 18 +++-- backend-rs/src/routing/mod.rs | 4 +- 6 files changed, 244 insertions(+), 23 deletions(-) diff --git a/backend-rs/src/db.rs b/backend-rs/src/db.rs index b467ec4..e21b559 100644 --- a/backend-rs/src/db.rs +++ b/backend-rs/src/db.rs @@ -1,10 +1,11 @@ +use color_eyre::eyre::{eyre, ContextCompat}; use queries::get_qp_search_query; -use sqlx::{postgres::PgPoolOptions, PgPool}; +use sqlx::{postgres::PgPoolOptions, PgPool, Postgres, Transaction}; use std::time::Duration; use crate::{ env::EnvVars, - qp::{self, Exam}, + qp::{self, Exam, Semester}, }; #[derive(Clone)] @@ -12,6 +13,15 @@ pub struct Database { connection: PgPool, } +pub struct EditDetails { + pub course_code: String, + pub course_name: String, + pub year: i32, + pub semester: Semester, + pub exam: Exam, + pub approve_status: bool, +} + impl Database { pub async fn try_new(env_vars: &EnvVars) -> Result { let database_url = format!( @@ -74,6 +84,76 @@ impl Database { Ok(paper.into()) } + + /// Edit's a paper's details. + /// + /// - If the paper is approved, the filename is also changed and it is moved to `/static_file_storage_location/uploaded_qps_path/approved/`. + /// - If the paper is unapproved, the file is not moved again, same goes for library papers. + /// - Sets the `approved_by` field to the username if approved. + /// + /// Returns the database transaction and the new filelink + pub async fn edit_paper<'c>( + &self, + id: i32, + edit_details: EditDetails, + newly_approved: bool, + username: &str, + env_vars: &EnvVars, + ) -> Result<(Transaction<'c, Postgres>, String), color_eyre::eyre::Error> { + let mut tx = self.connection.begin().await?; + + let EditDetails { + course_code, + course_name, + year, + semester, + exam, + approve_status, + } = edit_details; + + let semester = String::from(semester); + let exam = String::from(exam); + + let query_sql = queries::get_edit_paper_query(newly_approved); + let query = sqlx::query(&query_sql) + .bind(id) + .bind(&course_code) + .bind(&course_name) + .bind(year) + .bind(&semester) + .bind(&exam) + .bind(approve_status); + + let filelink = env_vars + .get_uploaded_paper_slugs() + .approved + .join(format!( + "{}_{}_{}_{}_{}_{}.pdf", + id, course_code, course_name, year, semester, exam + )) + .to_str() + .context("Error converting approved papers path to string.")? + .to_owned(); + + let query = if newly_approved { + query.bind(&filelink).bind(username) + } else { + query + }; + + let rows_affected = query.execute(&mut *tx).await?.rows_affected(); + + if rows_affected != 1 { + tx.rollback().await?; + + return Err(eyre!( + "An invalid number of rows were changed: {}", + rows_affected + )); + } + + Ok((tx, filelink)) + } } mod models { @@ -145,6 +225,25 @@ mod queries { /// Get a paper ([`crate::db::models::DBAdminDashboardQP`]) with the given id (first parameter `$1`) pub const GET_PAPER_BY_ID: &str = "SELECT id, filelink, from_library, course_code, course_name, year, semester, exam, upload_timestamp, approve_status FROM iqps WHERE id = $1"; + /// Returns a query that updates a paper's details by id ($1) (course_code, course_name, year, semester, exam, approve_status). `filelink` and `approved_by` optionally included if the edit is also used for approval. + /// + /// Query parameters: + /// - $1: `id` + /// - $2: `course_code` + /// - $3: `course_name` + /// - $4: `year` + /// - $5: `semester` + /// - $6: `exam` + /// - $7: `approve_status` + /// - $8: `filelink` + /// - $9: `approved_by` + pub fn get_edit_paper_query(approval: bool) -> String { + format!( + "UPDATE iqps set course_code=$2, course_name=$3, year=$4, semester=$5, exam=$6, approve_status=$7{} WHERE id=$1 AND is_deleted=false", + if approval {", filelink=$8, approved_by=$9"} else {""} + ) + } + /// Gets all unapproved papers ([`crate::db::models::DBAdminDashboardQP`]) from the database pub const GET_ALL_UNAPPROVED: &str = "SELECT id, filelink, from_library, course_code, course_name, year, semester, exam, upload_timestamp, approve_status FROM iqps WHERE approve_status = false and is_deleted=false ORDER BY upload_timestamp ASC"; diff --git a/backend-rs/src/env.rs b/backend-rs/src/env.rs index d1871d2..30fbf0d 100644 --- a/backend-rs/src/env.rs +++ b/backend-rs/src/env.rs @@ -58,7 +58,7 @@ pub struct EnvVars { #[arg(env, default_value = "/srv/static")] /// The path where static files are served from pub static_file_storage_location: PathBuf, - #[arg(env, default_value = "iqps/uploaded")] + #[arg(env, default_value = "/iqps/uploaded")] /// The path where uploaded papers are stored temporarily, relative to the `static_file_storage_location` pub uploaded_qps_path: PathBuf, @@ -73,6 +73,11 @@ pub struct EnvVars { pub cors_allowed_origins: String, } +pub struct UploadPaths { + pub unapproved: PathBuf, + pub approved: PathBuf, +} + impl EnvVars { /// Processes the environment variables after reading. pub fn process(mut self) -> Result> { @@ -90,4 +95,22 @@ impl EnvVars { pub fn get_jwt_key(&self) -> Result, InvalidLength> { Hmac::new_from_slice(self.jwt_secret.as_bytes()) } + + /// Gets the paths where (unapproved, approved) uploaded papers are stored + pub fn get_uploaded_paper_paths(&self) -> UploadPaths { + let slugs = self.get_uploaded_paper_slugs(); + + UploadPaths { + unapproved: self.static_file_storage_location.join(slugs.unapproved), + approved: self.static_file_storage_location.join(slugs.approved), + } + } + + /// Gets the slugs (relative paths stored in the db) where (unapproved, approved) uploaded papers are stored + pub fn get_uploaded_paper_slugs(&self) -> UploadPaths { + UploadPaths { + unapproved: self.uploaded_qps_path.join("unapproved"), + approved: self.uploaded_qps_path.join("approved"), + } + } } diff --git a/backend-rs/src/qp.rs b/backend-rs/src/qp.rs index 51a3d4d..fee7c87 100644 --- a/backend-rs/src/qp.rs +++ b/backend-rs/src/qp.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use color_eyre::eyre::eyre; use duplicate::duplicate_item; use serde::Serialize; @@ -85,11 +87,11 @@ impl From for String { } #[duplicate_item( - StrSerializeType; + ExamSem; [ Exam ]; [ Semester ]; )] -impl Serialize for StrSerializeType { +impl Serialize for ExamSem { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, @@ -139,4 +141,8 @@ impl QP { ..self }) } + + pub fn get_paper_path(&self, env_vars: &EnvVars) -> PathBuf { + env_vars.static_file_storage_location.join(&self.filelink) + } } diff --git a/backend-rs/src/routing/handlers.rs b/backend-rs/src/routing/handlers.rs index 2c260f3..161da6e 100644 --- a/backend-rs/src/routing/handlers.rs +++ b/backend-rs/src/routing/handlers.rs @@ -1,5 +1,6 @@ use axum::{extract::Json, http::StatusCode}; use serde::Serialize; +use tokio::fs; use std::collections::HashMap; @@ -8,7 +9,8 @@ use serde::Deserialize; use crate::{ auth, - qp::{self, AdminDashboardQP}, + db::EditDetails, + qp::{self, AdminDashboardQP, Exam, Semester}, }; use super::{AppError, BackendResponse, RouterState}; @@ -102,25 +104,108 @@ pub async fn profile(State(state): State) -> HandlerReturn, + pub course_name: Option, + pub year: Option, + pub semester: Option, + pub exam: Option, + pub approve_status: Option, +} +/// Paper edit endpoint (for admin dashboard) +/// Takes a JSON request body. The `id` field is required. +/// Other optional fields can be set to change that particular value in the paper. +pub async fn edit( + State(state): State, + Json(body): Json, +) -> HandlerReturn { + let EditReq { + id, + course_code, + course_name, + year, + semester, + exam, + approve_status, + } = body; + let auth = state.auth.lock().await; + + if let Some(auth) = auth.clone() { + let current_details = state.db.get_paper_by_id(id).await?; + let semester = if let Some(sem_str) = semester { + Semester::try_from(&sem_str)? + } else { + current_details.semester + }; + let exam = if let Some(exam_str) = exam { + Exam::try_from(&exam_str)? + } else { + current_details.exam + }; + + let newly_approved = !current_details.approve_status && approve_status.unwrap_or(false); + + // Edit the database entry + let (tx, filelink) = state + .db + .edit_paper( + id, + EditDetails { + course_code: course_code.unwrap_or(current_details.course_code.clone()), + course_name: course_name.unwrap_or(current_details.course_name.clone()), + year: year.unwrap_or(current_details.year), + semester, + exam, + approve_status: approve_status.unwrap_or(current_details.approve_status), }, + newly_approved, + &auth.username, + &state.env_vars, + ) + .await?; + + // Copy the actual file + let old_filepath = current_details.get_paper_path(&state.env_vars); + let new_filepath = state.env_vars.static_file_storage_location.join(filelink); + + if fs::copy(old_filepath, new_filepath).await.is_ok() { + // Get the new db entry + let new_paper = state.db.get_paper_by_id(id).await?; + + // Commit the transaction + tx.commit().await?; + + Ok(BackendResponse::ok( + "Successfully updated paper details.".into(), + new_paper.with_url(&state.env_vars)?, )) } else { + tx.rollback().await?; Ok(BackendResponse::error( - "Username not found in claims.".into(), - StatusCode::UNAUTHORIZED, + "Error copying question paper file.".into(), + StatusCode::INTERNAL_SERVER_ERROR, )) } } else { Ok(BackendResponse::error( - "Error: User unauthorized.".into(), + "Error getting authenticated user's username.".into(), StatusCode::UNAUTHORIZED, )) } diff --git a/backend-rs/src/routing/middleware.rs b/backend-rs/src/routing/middleware.rs index 942cb05..8aa82b7 100644 --- a/backend-rs/src/routing/middleware.rs +++ b/backend-rs/src/routing/middleware.rs @@ -20,12 +20,20 @@ pub async fn verify_jwt_middleware( let claims = auth::verify_token(jwt, &state.env_vars).await?; if let Some(claims) = claims { - let mut state_jwt = state.auth.lock().await; - *state_jwt = Auth { - jwt: jwt.to_string(), - claims, + if let Some(username) = claims.private.get("username") { + let mut state_jwt = state.auth.lock().await; + *state_jwt = Auth { + jwt: jwt.to_string(), + username: username.to_string(), + } + .into(); + } else { + return Ok(BackendResponse::<()>::error( + "Username not found in the claims.".into(), + StatusCode::UNAUTHORIZED, + ) + .into_response()); } - .into(); } else { return Ok(BackendResponse::<()>::error( "Authorization token invalid.".into(), diff --git a/backend-rs/src/routing/mod.rs b/backend-rs/src/routing/mod.rs index c790ea7..8f20c63 100644 --- a/backend-rs/src/routing/mod.rs +++ b/backend-rs/src/routing/mod.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use axum::{extract::Json, http::StatusCode, response::IntoResponse}; use http::{HeaderValue, Method}; -use jwt::Claims; use serde::Serialize; use tokio::sync::Mutex; use tower_http::{ @@ -28,6 +27,7 @@ pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router { axum::Router::new() .route("/unapproved", axum::routing::get(handlers::get_unapproved)) .route("/profile", axum::routing::get(handlers::profile)) + .route("/edit", axum::routing::get(handlers::edit)) .route_layer(axum::middleware::from_fn_with_state( state.clone(), middleware::verify_jwt_middleware, @@ -63,7 +63,7 @@ pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router { #[derive(Clone)] struct Auth { jwt: String, - claims: Claims, + username: String, } #[derive(Clone)] struct RouterState { From 030b42cf3deb0c9ab334b34118e7a067e2a0bb26 Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Sat, 12 Oct 2024 01:18:52 +0530 Subject: [PATCH 05/33] feat: use the edit endpoint (expect a few more commits fixing bugs) --- frontend/src/pages/AdminDashboard.tsx | 5 ++--- frontend/src/types/backend.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 7875dbd..c2173fb 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -28,9 +28,8 @@ function AdminDashboard() { // Only approves the paper rn // TODO: Allow unapproving papers as well - const response = await makeRequest('approve', 'post', { - ...qp, - filelink: new URL(qp.filelink).pathname // TODO: PLEASE DO THIS IN THE BAKCEND AHHHH ITS CALLED FILELINK NOT FILEPATH DED + const response = await makeRequest('edit', 'post', { + ...qp }, auth.jwt); if (response.status === "success") { diff --git a/frontend/src/types/backend.ts b/frontend/src/types/backend.ts index d63a9ef..ee6ea02 100644 --- a/frontend/src/types/backend.ts +++ b/frontend/src/types/backend.ts @@ -46,8 +46,16 @@ export interface IEndpointTypes { description: string; }[] }, - approve: { - request: IAdminDashboardQP, + edit: { + request: { + id: number, + course_code?: string, + course_name?: string, + year?: number, + semester?: string, + exam?: string, + approve_status?: boolean, + }, response: { id: number; } @@ -57,7 +65,7 @@ export interface IEndpointTypes { id: number; }, response: null; - }, + } profile: { request: null; response: { From d9221812907e041faebe8f53b65b9aeed0c54a52 Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Sat, 12 Oct 2024 01:23:43 +0530 Subject: [PATCH 06/33] fix: change get to post (this is not the commit i expected) --- backend-rs/src/routing/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-rs/src/routing/mod.rs b/backend-rs/src/routing/mod.rs index 8f20c63..832e12e 100644 --- a/backend-rs/src/routing/mod.rs +++ b/backend-rs/src/routing/mod.rs @@ -27,7 +27,7 @@ pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router { axum::Router::new() .route("/unapproved", axum::routing::get(handlers::get_unapproved)) .route("/profile", axum::routing::get(handlers::profile)) - .route("/edit", axum::routing::get(handlers::edit)) + .route("/edit", axum::routing::post(handlers::edit)) .route_layer(axum::middleware::from_fn_with_state( state.clone(), middleware::verify_jwt_middleware, From 481b6cdb0cec7d9541cfca880626553fa7f07fec Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Sat, 12 Oct 2024 01:25:32 +0530 Subject: [PATCH 07/33] featfix: did some stuff this is too late and i am exhausted --- frontend/src/components/Common/PaperEditModal.tsx | 2 +- frontend/src/pages/AdminDashboard.tsx | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Common/PaperEditModal.tsx b/frontend/src/components/Common/PaperEditModal.tsx index cc76ba7..202f151 100644 --- a/frontend/src/components/Common/PaperEditModal.tsx +++ b/frontend/src/components/Common/PaperEditModal.tsx @@ -245,7 +245,7 @@ function PaperEditModal(props: { 'approve_status' in data && - + From 671b1b54037d07a971b7025bd9f441883f063395 Mon Sep 17 00:00:00 2001 From: Harsh Khandeparkar Date: Fri, 18 Oct 2024 00:28:28 +0530 Subject: [PATCH 24/33] fix: changed course -> query --- frontend/src/components/Search/SearchForm.tsx | 22 +++++++++++-------- frontend/src/types/backend.ts | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Search/SearchForm.tsx b/frontend/src/components/Search/SearchForm.tsx index e1c7fbf..053db11 100644 --- a/frontend/src/components/Search/SearchForm.tsx +++ b/frontend/src/components/Search/SearchForm.tsx @@ -12,7 +12,11 @@ import { Select } from "../Common/Form"; function CourseSearchForm() { const currentURL = new URL(window.location.toString()); - const [query, setQuery] = useState(currentURL.searchParams.get('query') ?? ''); + const [query, setQuery] = useState( + currentURL.searchParams.get('query') ?? + currentURL.searchParams.get('course') ?? // `course` was previously used, keeping for backwards compatibility + '' + ); const [exam, setExam] = useState(currentURL.searchParams.get('exam') as Exam ?? ''); const [searchResults, setSearchResults] = useState([]); @@ -27,11 +31,11 @@ function CourseSearchForm() { const params = new URLSearchParams(); if (query === '') return; - params.append("course", query); + params.append("query", query); params.append("exam", exam); setAwaitingResponse(true); - const response = await makeRequest('search', 'get', {course: query, exam}); + const response = await makeRequest('search', 'get', { query, exam }); if (response.status === 'success') { const data: ISearchResult[] = response.data; @@ -80,7 +84,7 @@ function CourseSearchForm() { return
- + setQuery(courseInputRef.current?.value ?? '')} />
@@ -88,11 +92,11 @@ function CourseSearchForm() {