diff --git a/docs/.lock b/docs/.lock new file mode 100644 index 0000000..e69de29 diff --git a/docs/crates.js b/docs/crates.js new file mode 100644 index 0000000..3df809c --- /dev/null +++ b/docs/crates.js @@ -0,0 +1,2 @@ +window.ALL_CRATES = ["iqps_backend"]; +//{"start":21,"fragment_lengths":[14]} \ No newline at end of file diff --git a/docs/help.html b/docs/help.html new file mode 100644 index 0000000..9489277 --- /dev/null +++ b/docs/help.html @@ -0,0 +1 @@ +
pub async fn authenticate_user(
+ code: &String,
+ env_vars: &EnvVars,
+) -> Result<Option<String>, Error>
Takes a Github OAuth code and creates a JWT authentication token for the user
+Returns the JWT if the user is authenticated, None
otherwise.
Utils for Github OAuth integration and JWT authentication
+Currently this is only used in the admin dashboard and uses Github OAuth for authentication
+pub struct Auth {
+ pub jwt: String,
+ pub username: String,
+}
Struct containing the auth information of a user
+jwt: String
§username: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morestruct GithubAccessTokenResponse {
+ access_token: String,
+}
access_token: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morestruct GithubMembershipResponse {
+ state: String,
+}
state: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morestruct GithubUserResponse {
+ login: String,
+}
login: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read moreDatabase stuff. See submodules also.
+pub use queries::ExamFilter;
query_as()
function of sqlx. There is probably a better way to do this but this is my first time, sorry.pub struct DBAdminDashboardQP {
+ qp: DBBaseQP,
+ upload_timestamp: NaiveDateTime,
+ approve_status: bool,
+}
The fields of a question paper sent to the admin dashboard endpoint
+qp: DBBaseQP
§upload_timestamp: NaiveDateTime
§approve_status: bool
source
. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct DBBaseQP {
+ id: i32,
+ filelink: String,
+ from_library: bool,
+ course_code: String,
+ course_name: String,
+ year: i32,
+ semester: String,
+ exam: String,
+}
Base/common fields of a question paper
+id: i32
§filelink: String
§from_library: bool
§course_code: String
§course_name: String
§year: i32
§semester: String
§exam: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub const ADMIN_DASHBOARD_QP_FIELDS: &str = "id, filelink, from_library, course_code, course_name, year, semester, exam, upload_timestamp, approve_status";
List of fields in the crate::db::models::DBAdminDashboardQP
to be used with SELECT clauses
const INIT_DB: &str = "
+CREATE TABLE IF NOT EXISTS iqps (
+ id integer primary key GENERATED ALWAYS AS identity,
+ course_code TEXT NOT NULL DEFAULT '',
+ course_name TEXT NOT NULL,
+ year INTEGER NOT NULL,
+ exam TEXT NOT NULL DEFAULT '',
+ semester TEXT NOT NULL DEFAULT '',
+ filelink TEXT NOT NULL,
+ from_library BOOLEAN DEFAULT FALSE,
+ upload_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ approve_status BOOLEAN DEFAULT FALSE,
+ fts_course_details tsvector GENERATED ALWAYS AS (to_tsvector('english', course_code || ' ' || course_name)) stored
+);
+CREATE INDEX IF NOT EXISTS iqps_fts ON iqps USING gin (fts_course_details);
+CREATE EXTENSION pg_trgm;
+CREATE INDEX IF NOT EXISTS idx_course_name_trgm ON iqps USING gin (course_name gin_trgm_ops);";
Database initialization query. Not used by the backend directly.
+pub const INSERT_NEW_QP: &str = "INSERT INTO iqps (course_code, course_name, year, exam, semester, filelink, from_library) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id";
Insert a newly uploaded file in the db (and return the id)
+Parameters in the following order: course_code
, course_name
, year
, exam
, semester
, filelink
, from_library
pub enum ExamFilter {
+ Exam(Exam),
+ Any,
+ MidEnd,
+}
An enum representing the exam filter for the search query
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub fn get_all_unapproved_query() -> String
Gets all unapproved papers (crate::db::models::DBAdminDashboardQP
) from the database
pub fn get_edit_paper_query(approval: bool) -> String
Returns a query that updates a paper’s details by id ($1) (course_code, course_name, year, semester, exam, approve_status, filelink). approved_by
optionally included if the edit is also used for approval.
The query also returns all the admin dashboard qp fields of the edited paper
+Query parameters:
+id
course_code
course_name
year
semester
exam
approve_status
filelink
approved_by
pub fn get_get_paper_by_id_query() -> String
Get a paper (crate::db::models::DBAdminDashboardQP
) with the given id (first parameter $1
)
pub fn get_qp_search_query(exam_filter: ExamFilter) -> (String, bool)
Returns the query for searching question papers. It is mostly voodoo, @Rajiv please update the documentation.
+Optionally, the exam
argument can be used to also add a clause to match the exam field.
Query parameters: +$1 - Search query +$2 - Exam filter string (can be midsem, endsem, midend, or ct)
+Returns the query and a boolean representing whether the second argument is required
+pub fn get_similar_papers_query(
+ year: bool,
+ semester: bool,
+ exam: bool,
+) -> String
Query to get similar papers. Matches course_code
($1) always. Other parameters are optional and can be enabled or disabled using the arguments to this function.
Query parameters:
+$1
- course_code``
$2-
year
$3-
semester
$3-
exam`
SQL queries for the database.
+Some of these are functions that return a query that is dynamically generated based on requirements.
+crate::db::models::DBAdminDashboardQP
to be used with SELECT clausescourse_code
, course_name
, year
, exam
, semester
, filelink
, from_library
crate::db::models::DBSearchQP
] to be used with SELECT clausesapprove_status
to false and is_deleted
to true) of an uploaded paper.crate::db::models::DBAdminDashboardQP
) from the databaseapproved_by
optionally included if the edit is also used for approval.crate::db::models::DBAdminDashboardQP
) with the given id (first parameter $1
)course_code
($1) always. Other parameters are optional and can be enabled or disabled using the arguments to this function.struct Breh {
+ id: i32,
+}
Needed this to use the query_as()
function of sqlx. There is probably a better way to do this but this is my first time, sorry.
id: i32
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct Database {
+ connection: PgPool,
+}
The database
+connection: PgPool
Creates a new database connection given the environment variables.
+Fetches the list of all unapproved papers
+Searches for papers from a given query. Uses some voodoo black magic by @rajivharlalka
+Edit’s a paper’s details.
+approved_by
field to the username if approved.filelink
to:
+id_coursecode_coursename_year_semester_exam.pdf
and unapproved papers are moved to the unapproved directory and named id.pdf
Returns the database transaction, the old filelink and the new paper details (crate::qp::AdminDashboardQP
)
Sets the is_deleted
field to true and approve_status
to false. Only deletes uploaded papers.
Returns a boolean that represents whether a db entry was affected or not. If more than one entry was affected, an error will be thrown and the transaction will be rolled back.
+Returns all papers that match one or more of the specified properties exactly. course_name
is required, other properties are optional.
Inserts a new uploaded question paper into the database. Uses a placeholder for the filelink which should be replaced once the id is known using the crate::db::Database::update_uploaded_filelink function.
+Returns a tuple with the transaction and the id of the inserted paper.
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct EnvVars {Show 20 fields
+ pub db_name: String,
+ pub db_host: String,
+ pub db_port: String,
+ pub db_user: String,
+ pub db_password: String,
+ pub gh_client_id: String,
+ pub gh_client_secret: String,
+ pub gh_org_name: String,
+ pub gh_org_team_slug: String,
+ pub gh_org_admin_token: String,
+ jwt_secret: String,
+ pub max_upload_limit: usize,
+ pub log_location: PathBuf,
+ static_files_url: String,
+ static_file_storage_location: PathBuf,
+ uploaded_qps_path: PathBuf,
+ library_qps_path: PathBuf,
+ pub server_port: i32,
+ pub cors_allowed_origins: String,
+ pub paths: Paths,
+}
db_name: String
Database name
+db_host: String
Database hostname
+db_port: String
Database port
+db_user: String
Database username
+db_password: String
Database password
+gh_client_id: String
OAuth app client id (public token)
+gh_client_secret: String
OAuth app client secret
+gh_org_name: String
Github organization name
+gh_org_team_slug: String
Github organization team slug (this team has access to admin dashboard)
+gh_org_admin_token: String
An org admin’s Github token (with the read:org
permission)
jwt_secret: String
JWT encryption secret (make it a long, randomized string)
+max_upload_limit: usize
Maximum number of papers that can be uploaded at a time
+log_location: PathBuf
Location where logs are stored
+static_files_url: String
The URL of the static files server (odin’s vault)
+static_file_storage_location: PathBuf
The path where static files are served from
+uploaded_qps_path: PathBuf
The path where uploaded papers are stored temporarily, relative to the static_file_storage_location
library_qps_path: PathBuf
The path where library papers (scrapped) are stored, relative to the static_file_storage_location
server_port: i32
The port the server listens on
+cors_allowed_origins: String
List of origins allowed (as a list of values separated by commas origin1, origin2
)
paths: Paths
All paths must be handled using this
+ArgGroup::id
][crate::ArgGroup::id] for this set of argumentsCommand
] so it can instantiate Self
via
+[FromArgMatches::from_arg_matches_mut
] Read moreCommand
] so it can instantiate self
via
+[FromArgMatches::update_from_arg_matches_mut
] Read moreArgMatches
to self
.ArgMatches
to self
.self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morehandlers
, middleware
, state, and response utils.pub enum PaperCategory {
+ Unapproved,
+ Approved,
+ Library,
+}
A category of papers, can also be used to represent the directory where these papers are stored
+Unapproved paper
+Approved paper
+Library paper (scraped using the peqp scraper)
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read moreUtils for parsing paths on the server and to store/retrieve paths from the database +A “slug” is the part of the path common to the question paper and is stored in the database. Depending on the requirements, either a URL (eg: static.metakgp.org) or a path (/srv/static) can be prepended to the slug to get the final path to copy/serve/move the question paper to/from.
+struct PathTriad {
+ pub unapproved: PathBuf,
+ pub approved: PathBuf,
+ pub library: PathBuf,
+}
A set of paths (absolute, relative, or even URLs) for all three categories of papers (directories)
+unapproved: PathBuf
Unapproved paper path
+approved: PathBuf
Approved paper path
+library: PathBuf
Library paper path
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct Paths {
+ static_files_url: Url,
+ static_files_path: PathBuf,
+ system_paths: PathTriad,
+ path_slugs: PathTriad,
+}
Struct containing all the paths and URLs required to parse or create any question paper’s slug, absolute path, or URL.
+static_files_url: Url
URL of the static files server
+static_files_path: PathBuf
The absolute path to the location from where the static files server serves files
+system_paths: PathTriad
The absolute system paths to all three directories on the server
+path_slugs: PathTriad
The slugs to all three directories
+A slug is a relative path independent of the URL or system path. This slug is stored in the database and either the crate::pathutils::Paths::static_files_url
or the crate::pathutils::Paths::static_files_path
is prepended to it to get its URL (to send to the frontend) or the system path (for backend operations)
Creates a new Paths
struct
static_files_url
- The static files server URL (eg: https://static.metakgp.org)static_file_storage_location
- The path to the location on the server from which the static files are served (eg: /srv/static)uploaded_qps_relative_path
- The path to the uploaded question papers, relative to the static files storage location. (eg: /iqps/uploaded)library_qps_relative_path
- The path to the library question papers, relative to the static files storage location. (eg: /peqp/qp)Returns the slug for a given filename and paper category (directory)
+Returns the absolute system path for the specified directory and filename
+Returns the absolute system path from a given slug
+Returns the static server URL for the specified directory and filename
+Returns the static server URL for a given slug
+Removes any non-alphanumeric character and replaces whitespaces with -
+Also replaces /
with -
and multiple spaces or hyphens will be replaced with a single one
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub enum Exam {
+ Midsem,
+ Endsem,
+ CT(Option<usize>),
+ Unknown,
+}
Mid-semester examination, parsed from midsem
End-semester examination, parsed from endsem
Class test, parsed from either ct
or ct
followed by a number (eg: ct1
or ct10
).
The optional number represents the number of the class test (eg: class test 1 or class test 21). This will be None if the number is not known, parsed from ct
.
Unknown class test, parsed from an empty string.
+Note that this is different from an invalid value and is used to represent papers for which the exam is not known. An invalid value would be catto
or metakgp
for example.
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub enum Semester {
+ Autumn,
+ Spring,
+ Unknown,
+}
Autumn semester, parsed from autumn
Spring semester, parsed from spring
Unknown/wildcard semester, parsed from an empty string.
+Note that this is different from an invalid value and is used to represent papers for which the semester is not known. An invalid value would be puppy
or Hippopotomonstrosesquippedaliophobia
for example.
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct AdminDashboardQP {
+ pub qp: BaseQP,
+ pub upload_timestamp: String,
+ pub approve_status: bool,
+}
The fields of a question paper sent from the admin dashboard endpoints.
+This includes fields such as approve_status
and upload_timestamp
that would only be relevant to the dashboard.
qp: BaseQP
§upload_timestamp: String
§approve_status: bool
source
. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct BaseQP {
+ pub id: i32,
+ pub filelink: String,
+ pub from_library: bool,
+ pub course_code: String,
+ pub course_name: String,
+ pub year: i32,
+ pub semester: Semester,
+ pub exam: Exam,
+}
The fields of a question paper sent from the search endpoint
+id: i32
§filelink: String
§from_library: bool
§course_code: String
§course_name: String
§year: i32
§semester: Semester
§exam: Exam
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub trait WithUrl: Sized {
+ // Required method
+ fn with_url(self, env_vars: &EnvVars) -> Result<Self, Error>;
+}
This trait is not dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety", so this trait is not object safe.
enum Status {
+ Success,
+ Error,
+}
The status of a server response
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub async fn delete(
+ __arg0: State<RouterState>,
+ __arg1: Json<DeleteReq>,
+) -> Result<(StatusCode, BackendResponse<()>), AppError>
Deletes a given paper. Library papers cannot be deleted.
+Request format - DeleteReq
pub async fn edit(
+ __arg0: Extension<Auth>,
+ __arg1: State<RouterState>,
+ __arg2: Json<EditReq>,
+) -> Result<(StatusCode, BackendResponse<AdminDashboardQP>), AppError>
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.
Request format - EditReq
pub async fn get_unapproved(
+ __arg0: State<RouterState>,
+) -> Result<(StatusCode, BackendResponse<Vec<AdminDashboardQP>>), AppError>
Fetches all the unapproved papers.
+pub async fn healthcheck() -> Result<(StatusCode, BackendResponse<()>), AppError>
Healthcheck route. Returns a Hello World.
message if healthy.
pub async fn oauth(
+ __arg0: State<RouterState>,
+ __arg1: Json<OAuthReq>,
+) -> Result<(StatusCode, BackendResponse<OAuthRes>), AppError>
Takes a Github OAuth code and returns a JWT auth token to log in a user if authorized
+Request format - OAuthReq
pub async fn profile(
+ __arg0: Extension<Auth>,
+) -> Result<(StatusCode, BackendResponse<ProfileRes>), AppError>
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.
+pub async fn search(
+ __arg0: State<RouterState>,
+ __arg1: Query<HashMap<String, String>>,
+) -> Result<(StatusCode, BackendResponse<Vec<BaseQP>>), AppError>
Searches for question papers given a query and an optional exam
parameter.
query
: The query string to search in the question papers (searches course name or code)exam
(optional): A filter for the question paper by the exam field.pub async fn similar(
+ __arg0: State<RouterState>,
+ __arg1: Query<HashMap<String, String>>,
+) -> Result<(StatusCode, BackendResponse<Vec<AdminDashboardQP>>), AppError>
Fetches all question papers that match one or more properties specified. course_name
is compulsory.
course_code
: The course code of the question paper. (required)year
(optional): The year of the question paper.course_name
(optional): The course name (exact).semester
(optional): The semester (autumn/spring)exam
(optional): The exam field (midsem/endsem/ct)pub async fn upload(
+ __arg0: State<RouterState>,
+ multipart: Multipart,
+) -> Result<(StatusCode, BackendResponse<Vec<UploadStatus>>), AppError>
Uploads question papers to the server
+Request format - Multipart form with a file_details
field of the format FileDetails
All endpoint handlers and their response types.
+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)
The request format is described
+id
field is required.
+Other optional fields can be set to change that particular value in the paper.Hello World.
message if healthy.exam
parameter.course_name
is compulsory.pub struct DeleteReq {
+ id: i32,
+}
The request format for the delete endpoint
+id: i32
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct EditReq {
+ pub id: i32,
+ pub course_code: Option<String>,
+ pub course_name: Option<String>,
+ pub year: Option<i32>,
+ pub semester: Option<String>,
+ pub exam: Option<String>,
+ pub approve_status: Option<bool>,
+}
The request format for the paper edit endpoint
+id: i32
§course_code: Option<String>
§course_name: Option<String>
§year: Option<i32>
§semester: Option<String>
§exam: Option<String>
§approve_status: Option<bool>
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct FileDetails {
+ pub course_code: String,
+ pub course_name: String,
+ pub year: i32,
+ pub exam: String,
+ pub semester: String,
+ pub filename: String,
+}
The details for an uploaded question paper file
+course_code: String
§course_name: String
§year: i32
§exam: String
§semester: String
§filename: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct OAuthReq {
+ code: String,
+}
The request format for the OAuth endpoint
+code: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct OAuthRes {
+ token: String,
+}
The response format for the OAuth endpoint
+token: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct ProfileRes {
+ token: String,
+ username: String,
+}
The response format for the user profile endpoint
+token: String
§username: String
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morepub struct UploadStatus {
+ filename: String,
+ status: Status,
+ message: String,
+}
The status of an uploaded question paper file
+filename: String
The filename
+status: Status
Whether the file was successfully uploaded
+message: String
A message describing the status
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read moretype HandlerReturn<T> = Result<(StatusCode, BackendResponse<T>), AppError>;
The return type of a handler function. T is the data type returned if the operation was a success
+enum HandlerReturn<T> {
+ Ok((StatusCode, BackendResponse<T>)),
+ Err(AppError),
+}
Contains the success value
+Contains the error value
+Router, handlers
, middleware
, state, and response utils.
pub use handlers::EditReq;
pub use handlers::FileDetails;
?
operator can be used anywhere inside a handler to do so.pub async fn verify_jwt_middleware(
+ __arg0: State<RouterState>,
+ headers: HeaderMap,
+ request: Request,
+ next: Next,
+) -> Result<Response, AppError>
Verifies the JWT and authenticates a user. If the JWT is invalid, the user is sent an unauthorized status code. If the JWT is valid, the authentication is added to the state.
+Middleware for the axum router
+pub(crate) struct AppError(Error);
A struct representing the error returned by a handler. This is automatically serialized into JSON and sent as an internal server error (500) backend response. The ?
operator can be used anywhere inside a handler to do so.
0: Error
self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morestruct BackendResponse<T: Serialize> {
+ pub status: Status,
+ pub message: String,
+ pub data: Option<T>,
+}
Standard backend response format (serialized as JSON)
+status: Status
Whether the operation succeeded or failed
+message: String
A message describing the state of the operation (success/failure message)
+data: Option<T>
Any optional data sent (only sent if the operation was a success)
+self
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morestruct RouterState {
+ pub db: Database,
+ pub env_vars: EnvVars,
+}
The state of the axum router, containing the environment variables and the database connection.
+db: Database
§env_vars: EnvVars
source
. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left
is true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreself
into a Left
variant of Either<Self, Self>
+if into_left(&self)
returns true
.
+Converts self
into a Right
variant of Either<Self, Self>
+otherwise. Read moreOwoColorize::fg
or
+a color-specific method, such as OwoColorize::green
, Read moreOwoColorize::bg
or
+a color-specific method, such as OwoColorize::on_yellow
, Read moreSubscriber
to this type, returning a
+[WithDispatch
] wrapper. Read morehandlers
, middleware
, state, and response utils.\nStruct containing the auth information of a user\nTakes a Github OAuth code and creates a JWT authentication …\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nGenerates a JWT with the username (for claims) and secret …\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nVerifies whether a JWT is valid and signed with the secret …\nNeeded this to use the query_as()
function of sqlx. There …\nThe database\nEdit’s a paper’s details.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns all papers that match one or more of the specified …\nFetches the list of all unapproved papers\nInserts a new uploaded question paper into the database. …\nCalls U::from(self)
.\nCalls U::from(self)
.\nDatabase models.\nCreates a new database connection given the environment …\nSQL queries for the database.\nSearches for papers from a given query. Uses some voodoo …\nSets the is_deleted
field to true and approve_status
to …\nThe fields of a question paper sent to the admin dashboard …\nBase/common fields of a question paper\nReturns the argument unchanged.\nReturns the argument unchanged.\nCalls U::from(self)
.\nCalls U::from(self)
.\nList of fields in the crate::db::models::DBAdminDashboardQP
…\nAn enum representing the exam filter for the search query\nDatabase initialization query. Not used by the backend …\nInsert a newly uploaded file in the db (and return the id) …\nList of fields in the [crate::db::models::DBSearchQP
] to …\nSoft deletes a paper (sets approve_status
to false and …\nUpdates the filelink ($2) of a paper with the given id …\nReturns the argument unchanged.\nGets all unapproved papers (…\nReturns a query that updates a paper’s details by id …\nGet a paper (crate::db::models::DBAdminDashboardQP
) with …\nReturns the query for searching question papers. It is …\nQuery to get similar papers. Matches course_code
($1) …\nCalls U::from(self)
.\nList of origins allowed (as a list of values separated by …\nDatabase hostname\nDatabase name\nDatabase password\nDatabase port\nDatabase username\nReturns the argument unchanged.\nReturns the JWT signing key\nOAuth app client id (public token)\nOAuth app client secret\nAn org admin’s Github token (with the read:org
…\nGithub organization name\nGithub organization team slug (this team has access to …\nCalls U::from(self)
.\nJWT encryption secret (make it a long, randomized string)\nThe path where library papers (scrapped) are stored, …\nLocation where logs are stored\nMaximum number of papers that can be uploaded at a time\nAll paths must be handled using this\nProcesses the environment variables after reading.\nThe port the server listens on\nThe path where static files are served from\nThe URL of the static files server (odin’s vault)\nThe path where uploaded papers are stored temporarily, …\nApproved paper\nLibrary paper (scraped using the peqp scraper)\nA category of papers, can also be used to represent the …\nA set of paths (absolute, relative, or even URLs) for all …\nStruct containing all the paths and URLs required to parse …\nUnapproved paper\nApproved paper path\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nGets the path in the triad corresponding to the given …\nReturns the absolute system path for the specified …\nReturns the absolute system path from a given slug\nReturns the slug for a given filename and paper category …\nReturns the static server URL for the specified directory …\nReturns the static server URL for a given slug\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nLibrary paper path\nCreates a new Paths
struct\nThe slugs to all three directories\nRemoves any non-alphanumeric character and replaces …\nThe absolute path to the location from where the static …\nURL of the static files server\nThe absolute system paths to all three directories on the …\nUnapproved paper path\nThe fields of a question paper sent from the admin …\nAutumn semester, parsed from autumn
\nThe fields of a question paper sent from the search …\nClass test, parsed from either ct
or ct
followed by a …\nEnd-semester examination, parsed from endsem
\nRepresents the exam type of the paper.\nMid-semester examination, parsed from midsem
\nRepresents a semester.\nSpring semester, parsed from spring
\nUnknown/wildcard semester, parsed from an empty string.\nUnknown class test, parsed from an empty string.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nReturns the question paper with the full static files URL …\nA struct representing the error returned by a handler. …\nStandard backend response format (serialized as JSON)\nThe state of the axum router, containing the environment …\nThe status of a server response\nAny optional data sent (only sent if the operation was a …\nCreates a new error backend response with the given …\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the Axum router for IQPS\nAll endpoint handlers and their response types.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nA message describing the state of the operation …\nMiddleware for the axum router\nCreates a new success backend response with the given …\nWhether the operation succeeded or failed\nThe request format for the delete endpoint\nThe request format for the paper edit endpoint\nContains the error value\n10 MiB file size limit\nThe details for an uploaded question paper file\nThe return type of a handler function. T is the data type …\nThe request format for the OAuth endpoint\nThe response format for the OAuth endpoint\nContains the success value\nThe response format for the user profile endpoint\nThe status of an uploaded question paper file\nDeletes a given paper. Library papers cannot be deleted.\nPaper edit endpoint (for admin dashboard) Takes a JSON …\nThe filename\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nReturns the argument unchanged.\nFetches all the unapproved papers.\nHealthcheck route. Returns a Hello World.
message if …\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nCalls U::from(self)
.\nA message describing the status\nTakes a Github OAuth code and returns a JWT auth token to …\nReturns a user’s profile (the JWT and username) if …\nSearches for question papers given a query and an optional …\nFetches all question papers that match one or more …\nWhether the file was successfully uploaded\nUploads question papers to the server\nVerifies the JWT and authenticates a user. If the JWT is …")
\ No newline at end of file
diff --git a/docs/settings.html b/docs/settings.html
new file mode 100644
index 0000000..a277db0
--- /dev/null
+++ b/docs/settings.html
@@ -0,0 +1 @@
++1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193
//! Utils for Github OAuth integration and JWT authentication
+//!
+//! Currently this is only used in the admin dashboard and uses Github OAuth for authentication
+
+use std::collections::BTreeMap;
+
+use color_eyre::eyre::{eyre, Context, ContextCompat};
+use http::StatusCode;
+use jwt::{Claims, RegisteredClaims, SignWithKey, VerifyWithKey};
+use serde::Deserialize;
+
+use crate::env::EnvVars;
+
+#[derive(Clone)]
+/// Struct containing the auth information of a user
+pub struct Auth {
+ pub jwt: String,
+ pub username: String,
+}
+
+/// Verifies whether a JWT is valid and signed with the secret key
+///
+/// Returns the username and jwt in a struct
+pub async fn verify_token(
+ token: &str,
+ env_vars: &EnvVars,
+) -> Result<Auth, color_eyre::eyre::Error> {
+ let jwt_key = env_vars.get_jwt_key()?;
+ let claims: Result<Claims, _> = token.verify_with_key(&jwt_key);
+
+ let claims = claims.map_err(|_| eyre!("Claims not found on the JWT."))?;
+ let username = claims
+ .private
+ .get("username")
+ .ok_or(eyre!("Username not in the claims."))?;
+ let username = username
+ .as_str()
+ .ok_or(eyre!("Username is not a string."))?;
+
+ Ok(Auth {
+ jwt: token.to_owned(),
+ username: username.to_owned(),
+ })
+}
+
+/// Generates a JWT with the username (for claims) and secret key
+async fn generate_token(
+ username: &str,
+ env_vars: &EnvVars,
+) -> Result<String, color_eyre::eyre::Error> {
+ let jwt_key = env_vars.get_jwt_key()?;
+
+ let expiration = chrono::Utc::now()
+ .checked_add_days(chrono::naive::Days::new(7)) // 7 Days expiration
+ .context("Error: error setting JWT expiry date")?
+ .timestamp()
+ .unsigned_abs();
+
+ let mut private_claims = BTreeMap::new();
+ private_claims.insert(
+ "username".into(),
+ serde_json::Value::String(username.into()),
+ );
+
+ let claims = Claims {
+ registered: RegisteredClaims {
+ audience: None,
+ issued_at: None,
+ issuer: None,
+ subject: None,
+ not_before: None,
+ json_web_token_id: None,
+ expiration: Some(expiration),
+ },
+ private: private_claims,
+ };
+
+ Ok(claims.sign_with_key(&jwt_key)?)
+}
+
+#[derive(Deserialize)]
+struct GithubAccessTokenResponse {
+ access_token: String,
+}
+
+#[derive(Deserialize)]
+struct GithubUserResponse {
+ login: String,
+}
+
+#[derive(Deserialize)]
+struct GithubMembershipResponse {
+ state: String,
+}
+
+/// Takes a Github OAuth code and creates a JWT authentication token for the user
+/// 1. Uses the OAuth code to get an access token.
+/// 2. Uses the access token to get the user's username.
+/// 3. Uses the username and and a admin's access token to verify whether the user is a member of the admins github team.
+///
+/// Returns the JWT if the user is authenticated, `None` otherwise.
+pub async fn authenticate_user(
+ code: &String,
+ env_vars: &EnvVars,
+) -> Result<Option<String>, color_eyre::eyre::Error> {
+ let client = reqwest::Client::new();
+
+ // Get the access token for authenticating other endpoints
+ let response = client
+ .get(format!(
+ "https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",
+ env_vars.gh_client_id, env_vars.gh_client_secret, code
+ ))
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .context("Error getting access token from Github.")?;
+
+ if response.status() != StatusCode::OK {
+ tracing::error!(
+ "Github OAuth error getting access token: {}",
+ response.text().await?
+ );
+
+ return Err(eyre!("Github API response error."));
+ }
+
+ let access_token =
+ serde_json::from_slice::<GithubAccessTokenResponse>(&response.bytes().await?)
+ .context("Error parsing access token response.")?
+ .access_token;
+
+ // Get the username of the user who made the request
+ let response = client
+ .get("https://api.github.com/user")
+ .header("Authorization", format!("Bearer {}", access_token))
+ .header("User-Agent", "bruh") // Why is this required :ded:
+ .send()
+ .await
+ .context("Error fetching user's username.")?;
+
+ if response.status() != StatusCode::OK {
+ tracing::error!(
+ "Github OAuth error getting username: {}",
+ response.text().await?
+ );
+
+ return Err(eyre!("Github API response error."));
+ }
+
+ let username = serde_json::from_slice::<GithubUserResponse>(&response.bytes().await?)
+ .context("Error parsing username API response.")?
+ .login;
+
+ // Check the user's membership in the team
+ println!(
+ "https://api.github.com/orgs/{}/teams/{}/memberships/{}",
+ env_vars.gh_org_name, env_vars.gh_org_team_slug, username
+ );
+
+ let response = client
+ .get(format!(
+ "https://api.github.com/orgs/{}/teams/{}/memberships/{}",
+ env_vars.gh_org_name, env_vars.gh_org_team_slug, username
+ ))
+ .header(
+ "Authorization",
+ format!("Bearer {}", env_vars.gh_org_admin_token),
+ )
+ .header("User-Agent", "bruh why is this required")
+ .send()
+ .await
+ .context("Error getting user's team membership")?;
+
+ if response.status() != StatusCode::OK {
+ tracing::error!(
+ "Github OAuth error getting membership status: {}",
+ response.text().await?
+ );
+
+ return Err(eyre!("Github API response error."));
+ }
+
+ let state = serde_json::from_slice::<GithubMembershipResponse>(&response.bytes().await?)
+ .context("Error parsing membership API response.")?
+ .state;
+
+ if state != "active" {
+ Ok(None)
+ } else {
+ Ok(Some(generate_token(&username, env_vars).await?))
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293
//! Database stuff. See submodules also.
+
+use color_eyre::eyre::eyre;
+use models::DBAdminDashboardQP;
+pub use queries::ExamFilter;
+use sqlx::{postgres::PgPoolOptions, prelude::FromRow, PgPool, Postgres, Transaction};
+use std::time::Duration;
+
+use crate::{
+ env::EnvVars,
+ pathutils::{PaperCategory, Paths},
+ qp::{self, AdminDashboardQP, Exam, Semester},
+ routing::{EditReq, FileDetails},
+};
+
+mod models;
+mod queries;
+
+#[derive(Clone)]
+/// The database
+pub struct Database {
+ connection: PgPool,
+}
+
+#[derive(FromRow)]
+/// Needed this to use the `query_as()` function of sqlx. There is probably a better way to do this but this is my first time, sorry.
+struct Breh {
+ id: i32,
+}
+
+impl Database {
+ /// Creates a new database connection given the environment variables.
+ pub async fn new(env_vars: &EnvVars) -> Result<Self, sqlx::Error> {
+ let database_url = format!(
+ "postgres://{}:{}@{}:{}/{}",
+ env_vars.db_user,
+ env_vars.db_password,
+ env_vars.db_host,
+ env_vars.db_port,
+ env_vars.db_name
+ );
+
+ let conn_pool = PgPoolOptions::new()
+ .max_connections(5)
+ .acquire_timeout(Duration::from_secs(3))
+ .connect(&database_url)
+ .await?;
+
+ Ok(Self {
+ connection: conn_pool,
+ })
+ }
+
+ /// Fetches the list of all unapproved papers
+ pub async fn get_unapproved_papers(&self) -> Result<Vec<qp::AdminDashboardQP>, sqlx::Error> {
+ let query_sql = queries::get_all_unapproved_query();
+ let papers: Vec<models::DBAdminDashboardQP> = sqlx::query_as(&query_sql)
+ .fetch_all(&self.connection)
+ .await?;
+
+ Ok(papers
+ .iter()
+ .map(|qp| qp::AdminDashboardQP::from(qp.clone()))
+ .collect())
+ }
+
+ /// Searches for papers from a given query. Uses some voodoo black magic by @rajivharlalka
+ pub async fn search_papers(
+ &self,
+ query: &str,
+ exam_filter: ExamFilter,
+ exam_filter_str: String,
+ ) -> Result<Vec<qp::BaseQP>, sqlx::Error> {
+ let (query_sql, use_exam_arg) = queries::get_qp_search_query(exam_filter);
+ let query = sqlx::query_as(&query_sql).bind(query);
+
+ let query = if use_exam_arg {
+ query.bind(exam_filter_str)
+ } else {
+ query
+ };
+
+ let papers: Vec<models::DBBaseQP> = query.fetch_all(&self.connection).await?;
+
+ Ok(papers
+ .iter()
+ .map(|qp| qp::BaseQP::from(qp.clone()))
+ .collect())
+ }
+
+ pub async fn get_paper_by_id(&self, id: i32) -> Result<qp::AdminDashboardQP, sqlx::Error> {
+ let query_sql = queries::get_get_paper_by_id_query();
+ let query = sqlx::query_as(&query_sql).bind(id);
+
+ let paper: models::DBAdminDashboardQP = query.fetch_one(&self.connection).await?;
+
+ Ok(paper.into())
+ }
+
+ /// Edit's a paper's details.
+ ///
+ /// - Sets the `approved_by` field to the username if approved.
+ /// - Sets the `filelink` to:
+ /// - For library papers, remains unchanged
+ /// - For uploaded papers, approved papers are moved to the approved directory and renamed `id_coursecode_coursename_year_semester_exam.pdf` and unapproved papers are moved to the unapproved directory and named `id.pdf`
+ ///
+ /// Returns the database transaction, the old filelink and the new paper details ([`crate::qp::AdminDashboardQP`])
+ pub async fn edit_paper<'c>(
+ &self,
+ edit_req: EditReq,
+ username: &str,
+ env_vars: &EnvVars,
+ ) -> Result<(Transaction<'c, Postgres>, String, AdminDashboardQP), color_eyre::eyre::Error>
+ {
+ let EditReq {
+ id,
+ course_code,
+ course_name,
+ year,
+ semester,
+ exam,
+ approve_status,
+ } = edit_req;
+
+ let current_details = self.get_paper_by_id(id).await?;
+
+ // Construct the final values to be inserted into the db
+ let course_code = course_code.unwrap_or(current_details.qp.course_code);
+ let course_name = course_name.unwrap_or(current_details.qp.course_name);
+ let year = year.unwrap_or(current_details.qp.year);
+ let semester: String = semester
+ .map(|sem| Semester::try_from(&sem))
+ .transpose()?
+ .unwrap_or(current_details.qp.semester)
+ .into();
+ let exam: String = exam
+ .map(|exam| Exam::try_from(&exam))
+ .transpose()?
+ .unwrap_or(current_details.qp.exam)
+ .into();
+ let approve_status = approve_status.unwrap_or(current_details.approve_status);
+
+ // Set the new filelink
+ let old_filelink = current_details.qp.filelink;
+ let new_filelink = if current_details.qp.from_library {
+ old_filelink.clone()
+ } else if approve_status {
+ env_vars.paths.get_slug(
+ &format!(
+ "{}.pdf",
+ Paths::sanitize_path(&format!(
+ "{}_{}_{}_{}_{}_{}",
+ id, course_code, course_name, year, semester, exam
+ ))
+ ),
+ PaperCategory::Approved,
+ )
+ } else {
+ env_vars
+ .paths
+ .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved)
+ };
+
+ let mut tx = self.connection.begin().await?;
+
+ let query_sql = queries::get_edit_paper_query(approve_status);
+ let query = sqlx::query_as(&query_sql)
+ .bind(id)
+ .bind(&course_code)
+ .bind(&course_name)
+ .bind(year)
+ .bind(&semester)
+ .bind(&exam)
+ .bind(approve_status)
+ .bind(&new_filelink);
+
+ let query = if approve_status {
+ query.bind(username)
+ } else {
+ query
+ };
+
+ let new_qp: DBAdminDashboardQP = query.fetch_one(&mut *tx).await?;
+ let new_qp = AdminDashboardQP::from(new_qp);
+
+ Ok((tx, old_filelink, new_qp))
+ }
+
+ // /// Adds a new upload paper's details to the database. Sets the `from_library` field to false.
+ // ///
+ // /// Returns the database transaction and the id of the uploaded paper
+ // pub async fn add_uploaded_paper<'c>(
+ // &self,
+ // file_details:
+ // ) -> Result<(Transaction<'c, Postgres>, i32), color_eyre::eyre::Error> {
+ // }
+
+ /// Sets the `is_deleted` field to true and `approve_status` to false. Only deletes uploaded papers.
+ ///
+ /// Returns a boolean that represents whether a db entry was affected or not. If more than one entry was affected, an error will be thrown and the transaction will be rolled back.
+ pub async fn soft_delete(&self, id: i32) -> Result<bool, color_eyre::eyre::Error> {
+ let mut tx = self.connection.begin().await?;
+
+ let rows_affected = sqlx::query(queries::SOFT_DELETE_BY_ID)
+ .bind(id)
+ .execute(&mut *tx)
+ .await?
+ .rows_affected();
+
+ if rows_affected > 1 {
+ tx.rollback().await?;
+ Err(eyre!(
+ "Error: {} (> 1) papers were deleted. Rolling back.",
+ rows_affected
+ ))
+ } else {
+ tx.commit().await?;
+ Ok(rows_affected == 1)
+ }
+ }
+
+ /// Returns all papers that match one or more of the specified properties exactly. `course_name` is required, other properties are optional.
+ pub async fn get_similar_papers(
+ &self,
+ course_code: &str,
+ year: Option<i32>,
+ semester: Option<&String>,
+ exam: Option<&String>,
+ ) -> Result<Vec<AdminDashboardQP>, sqlx::Error> {
+ let query_sql =
+ queries::get_similar_papers_query(year.is_some(), semester.is_some(), exam.is_some());
+ let query = sqlx::query_as(&query_sql).bind(course_code);
+
+ let query = query.bind(year);
+ let query = query.bind(semester);
+ let query = query.bind(exam);
+
+ let papers: Vec<models::DBAdminDashboardQP> = query.fetch_all(&self.connection).await?;
+
+ Ok(papers
+ .iter()
+ .map(|qp| qp::AdminDashboardQP::from(qp.clone()))
+ .collect())
+ }
+
+ /// Inserts a new uploaded question paper into the database. Uses a placeholder for the filelink which should be replaced once the id is known using the [crate::db::Database::update_uploaded_filelink] function.
+ ///
+ /// Returns a tuple with the transaction and the id of the inserted paper.
+ pub async fn insert_new_uploaded_qp<'c>(
+ &self,
+ file_details: &FileDetails,
+ ) -> Result<(Transaction<'c, Postgres>, i32), color_eyre::eyre::Error> {
+ let mut tx = self.connection.begin().await?;
+
+ let FileDetails {
+ course_code,
+ course_name,
+ year,
+ exam,
+ semester,
+ ..
+ } = file_details;
+
+ let query = sqlx::query_as(queries::INSERT_NEW_QP)
+ .bind(course_code)
+ .bind(course_name)
+ .bind(year)
+ .bind(exam)
+ .bind(semester)
+ .bind("placeholder_filelink")
+ .bind(false);
+
+ let Breh { id } = query.fetch_one(&mut *tx).await?;
+
+ Ok((tx, id))
+ }
+
+ // /// Updates filelink for an uploaded question paper uploaded using the [crate::db::Database::update_uploaded_filelink] function. Takes the same transaction that the previous function used.
+ pub async fn update_uploaded_filelink(
+ &self,
+ tx: &mut Transaction<'_, Postgres>,
+ id: i32,
+ file_link: &str,
+ ) -> Result<(), color_eyre::eyre::Error> {
+ let query = sqlx::query(queries::UPDATE_FILELINK)
+ .bind(id)
+ .bind(file_link);
+
+ query.execute(&mut **tx).await?;
+
+ Ok(())
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57
//! Database models.
+//!
+//! These can be (should be) converted to the structs in [`crate::qp`] for sending as a response since the struct also parses the `semester` and `exam` fields and also generates the full static files URL.
+//!
+//! Use the [`From`] trait implementations.
+
+use crate::qp::Semester;
+
+use super::qp;
+use sqlx::{prelude::FromRow, types::chrono};
+
+#[derive(FromRow, Clone)]
+/// Base/common fields of a question paper
+pub struct DBBaseQP {
+ id: i32,
+ filelink: String,
+ from_library: bool,
+ course_code: String,
+ course_name: String,
+ year: i32,
+ semester: String,
+ exam: String,
+}
+
+#[derive(FromRow, Clone)]
+/// The fields of a question paper sent to the admin dashboard endpoint
+pub struct DBAdminDashboardQP {
+ #[sqlx(flatten)]
+ qp: DBBaseQP,
+ upload_timestamp: chrono::NaiveDateTime,
+ approve_status: bool,
+}
+
+impl From<DBAdminDashboardQP> for qp::AdminDashboardQP {
+ fn from(value: DBAdminDashboardQP) -> Self {
+ Self {
+ qp: value.qp.into(),
+ upload_timestamp: value.upload_timestamp.to_string(),
+ approve_status: value.approve_status,
+ }
+ }
+}
+
+impl From<DBBaseQP> for qp::BaseQP {
+ fn from(value: DBBaseQP) -> Self {
+ Self {
+ id: value.id,
+ filelink: value.filelink,
+ from_library: value.from_library,
+ course_code: value.course_code,
+ course_name: value.course_name,
+ year: value.year,
+ semester: (&value.semester).try_into().unwrap_or(Semester::Unknown),
+ exam: (&value.exam).try_into().unwrap_or(qp::Exam::Unknown),
+ }
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207
//! SQL queries for the database.
+//!
+//! Some of these are functions that return a query that is dynamically generated based on requirements.
+
+use crate::qp::Exam;
+
+/// Database initialization query. Not used by the backend directly.
+#[allow(dead_code)]
+const INIT_DB: &str = "
+CREATE TABLE IF NOT EXISTS iqps (
+ id integer primary key GENERATED ALWAYS AS identity,
+ course_code TEXT NOT NULL DEFAULT '',
+ course_name TEXT NOT NULL,
+ year INTEGER NOT NULL,
+ exam TEXT NOT NULL DEFAULT '',
+ semester TEXT NOT NULL DEFAULT '',
+ filelink TEXT NOT NULL,
+ from_library BOOLEAN DEFAULT FALSE,
+ upload_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ approve_status BOOLEAN DEFAULT FALSE,
+ fts_course_details tsvector GENERATED ALWAYS AS (to_tsvector('english', course_code || ' ' || course_name)) stored
+);
+CREATE INDEX IF NOT EXISTS iqps_fts ON iqps USING gin (fts_course_details);
+CREATE EXTENSION pg_trgm;
+CREATE INDEX IF NOT EXISTS idx_course_name_trgm ON iqps USING gin (course_name gin_trgm_ops);";
+
+/// Query to get similar papers. Matches `course_code` ($1) always. Other parameters are optional and can be enabled or disabled using the arguments to this function.
+///
+/// Query parameters:
+/// `$1` - `course_code``
+/// `$2` - `year`
+/// `$3` - `semester`
+/// `$3` - `exam`
+pub fn get_similar_papers_query(year: bool, semester: bool, exam: bool) -> String {
+ let mut param_num = 1;
+
+ format!(
+ "SELECT {} from iqps where is_deleted=false and course_code = $1 {} {} {}",
+ ADMIN_DASHBOARD_QP_FIELDS,
+ if year {
+ param_num += 1;
+ format!("AND year=${}", param_num)
+ } else {
+ "".to_string()
+ },
+ if semester {
+ param_num += 1;
+ format!("AND semester=${}", param_num)
+ } else {
+ "".to_string()
+ },
+ if exam {
+ param_num += 1;
+ format!("AND exam=${}", param_num)
+ } else {
+ "".to_string()
+ },
+ )
+}
+
+/// Soft deletes a paper (sets `approve_status` to false and `is_deleted` to true) of an uploaded paper.
+pub const SOFT_DELETE_BY_ID: &str =
+ "UPDATE iqps SET approve_status=false, is_deleted = true WHERE id=$1 AND from_library = false";
+
+/// Get a paper ([`crate::db::models::DBAdminDashboardQP`]) with the given id (first parameter `$1`)
+pub fn get_get_paper_by_id_query() -> String {
+ format!(
+ "SELECT {} FROM iqps WHERE id = $1",
+ ADMIN_DASHBOARD_QP_FIELDS
+ )
+}
+
+/// Returns a query that updates a paper's details by id ($1) (course_code, course_name, year, semester, exam, approve_status, filelink). `approved_by` optionally included if the edit is also used for approval.
+///
+/// The query also returns all the admin dashboard qp fields of the edited paper
+///
+/// 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, filelink=$8{} WHERE id=$1 AND is_deleted=false RETURNING {}",
+ if approval {", approved_by=$9"} else {""},
+ ADMIN_DASHBOARD_QP_FIELDS
+ )
+}
+
+/// Gets all unapproved papers ([`crate::db::models::DBAdminDashboardQP`]) from the database
+pub fn get_all_unapproved_query() -> String {
+ format!("SELECT {} FROM iqps WHERE approve_status = false and is_deleted=false ORDER BY upload_timestamp ASC", ADMIN_DASHBOARD_QP_FIELDS)
+}
+
+/// An enum representing the exam filter for the search query
+pub enum ExamFilter {
+ Exam(Exam), // Match an exact exam or use `ct` substring match
+ Any, // Match anything
+ MidEnd, // Midsem or endsem
+}
+
+impl TryFrom<&String> for ExamFilter {
+ type Error = color_eyre::eyre::Error;
+
+ fn try_from(value: &String) -> Result<Self, Self::Error> {
+ if value.is_empty() {
+ Ok(ExamFilter::Any)
+ } else if value == "midend" {
+ Ok(ExamFilter::MidEnd)
+ } else {
+ Ok(ExamFilter::Exam(Exam::try_from(value)?))
+ }
+ }
+}
+
+/// Returns the query for searching question papers. It is mostly voodoo, @Rajiv please update the documentation.
+///
+/// Optionally, the `exam` argument can be used to also add a clause to match the exam field.
+///
+/// Query parameters:
+/// $1 - Search query
+/// $2 - Exam filter string (can be midsem, endsem, midend, or ct)
+///
+/// Returns the query and a boolean representing whether the second argument is required
+pub fn get_qp_search_query(exam_filter: ExamFilter) -> (String, bool) {
+ let (exam_filter, use_exam_arg) = match exam_filter {
+ ExamFilter::Any => ("", false),
+ ExamFilter::MidEnd => (
+ "WHERE (exam = 'midsem' OR exam = 'endsem' OR exam = '')",
+ false,
+ ),
+ ExamFilter::Exam(exam) => match exam {
+ Exam::CT(_) => ("WHERE (exam LIKE 'ct%' OR exam = '')", false),
+ _ => ("WHERE (exam = $2 OR exam = '')", true),
+ },
+ };
+
+ (
+ format!("
+ WITH filtered AS (
+ SELECT * from iqps {exam_filter} ORDER BY year DESC
+ ),
+ fuzzy AS (
+ SELECT id,
+ similarity(course_code || ' ' || course_name, $1) AS sim_score,
+ row_number() OVER (ORDER BY similarity(course_code || ' ' || course_name, $1) DESC) AS rank_ix
+ FROM filtered
+ WHERE (course_code || ' ' || course_name) %>> $1 AND approve_status = true
+ ORDER BY rank_ix
+ LIMIT 30
+ ),
+ full_text AS (
+ SELECT id,
+ ts_rank_cd(fts_course_details, websearch_to_tsquery($1)) AS rank_score,
+ row_number() OVER (ORDER BY ts_rank_cd(fts_course_details, websearch_to_tsquery($1)) DESC) AS rank_ix
+ FROM filtered
+ WHERE fts_course_details @@ websearch_to_tsquery($1) AND approve_status = true
+ ORDER BY rank_ix
+ LIMIT 30
+ ),
+ partial_search AS (
+ SELECT id,
+ ts_rank_cd(fts_course_details, {to_tsquery}) AS rank_score,
+ row_number() OVER (ORDER BY ts_rank_cd(fts_course_details, {to_tsquery}) DESC) as rank_ix
+ FROM filtered
+ WHERE fts_course_details @@ {to_tsquery} AND approve_status = true
+ LIMIT 30
+ ),
+ result AS (
+ SELECT {intermediate_fields}
+ FROM fuzzy
+ FULL OUTER JOIN full_text ON fuzzy.id = full_text.id
+ FULL OUTER JOIN partial_search ON coalesce(fuzzy.id, full_text.id) = partial_search.id
+ JOIN filtered ON coalesce(fuzzy.id, full_text.id, partial_search.id) = filtered.id
+ ORDER BY
+ coalesce(1.0 / (50 + fuzzy.rank_ix), 0.0) * 1 +
+ coalesce(1.0 / (50 + full_text.rank_ix), 0.0) * 1 +
+ coalesce(1.0 / (50 + partial_search.rank_ix), 0.0) * 1
+ DESC
+ ) SELECT {search_qp_fields} FROM result",
+ search_qp_fields = SEARCH_QP_FIELDS,
+ to_tsquery = "to_tsquery('simple', websearch_to_tsquery('simple', $1)::text || ':*')",
+ exam_filter = exam_filter,
+ intermediate_fields = ADMIN_DASHBOARD_QP_FIELDS.split(", ").map(|field| format!("filtered.{}", field)).collect::<Vec<String>>().join(", ")
+ ),
+ use_exam_arg
+ )
+}
+
+/// List of fields in the [`crate::db::models::DBAdminDashboardQP`] to be used with SELECT clauses
+pub const ADMIN_DASHBOARD_QP_FIELDS: &str = "id, filelink, from_library, course_code, course_name, year, semester, exam, upload_timestamp, approve_status";
+
+/// List of fields in the [`crate::db::models::DBSearchQP`] to be used with SELECT clauses
+pub const SEARCH_QP_FIELDS: &str =
+ "id, filelink, from_library, course_code, course_name, year, semester, exam";
+
+/// Insert a newly uploaded file in the db (and return the id)
+/// Parameters in the following order: `course_code`, `course_name`, `year`, `exam`, `semester`, `filelink`, `from_library`
+pub const INSERT_NEW_QP: &str = "INSERT INTO iqps (course_code, course_name, year, exam, semester, filelink, from_library) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id";
+
+/// Updates the filelink ($2) of a paper with the given id ($1). Used to update the filelink after a paper is uploaded.
+pub const UPDATE_FILELINK: &str = "UPDATE iqps SET filelink=$2 WHERE id=$1";
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107
//! ### Environment Variables
+//!
+//! Each field in the struct `EnvVars` corresponds to an environment variable. The environment variable name will be in all capitals. The default values are set using the `arg()` macro of the `clap` crate. Check the source code for the defaults.
+
+use std::path::PathBuf;
+
+use clap::Parser;
+use hmac::{digest::InvalidLength, Hmac, Mac};
+use sha2::Sha256;
+
+use crate::pathutils::Paths;
+
+#[derive(Parser, Clone)]
+pub struct EnvVars {
+ // Database
+ #[arg(env)]
+ /// Database name
+ pub db_name: String,
+ #[arg(env)]
+ /// Database hostname
+ pub db_host: String,
+ #[arg(env)]
+ /// Database port
+ pub db_port: String,
+ #[arg(env)]
+ /// Database username
+ pub db_user: String,
+ #[arg(env)]
+ /// Database password
+ pub db_password: String,
+
+ // Auth
+ #[arg(env)]
+ /// OAuth app client id (public token)
+ pub gh_client_id: String,
+ #[arg(env)]
+ /// OAuth app client secret
+ pub gh_client_secret: String,
+ #[arg(env)]
+ /// Github organization name
+ pub gh_org_name: String,
+ #[arg(env)]
+ /// Github organization team slug (this team has access to admin dashboard)
+ pub gh_org_team_slug: String,
+ #[arg(env)]
+ /// An org admin's Github token (with the `read:org` permission)
+ pub gh_org_admin_token: String,
+ #[arg(env)]
+ /// JWT encryption secret (make it a long, randomized string)
+ jwt_secret: String,
+
+ // Other configs
+ #[arg(env, default_value = "10")]
+ /// Maximum number of papers that can be uploaded at a time
+ pub max_upload_limit: usize,
+ #[arg(env, default_value = "./log/application.log")]
+ /// Location where logs are stored
+ pub log_location: PathBuf,
+
+ // Paths
+ #[arg(env, default_value = "https://static.metakgp.org")]
+ /// The URL of the static files server (odin's vault)
+ static_files_url: String,
+ #[arg(env, default_value = "/srv/static")]
+ /// The path where static files are served from
+ static_file_storage_location: PathBuf,
+ #[arg(env, default_value = "/iqps/uploaded")]
+ /// The path where uploaded papers are stored temporarily, relative to the `static_file_storage_location`
+ uploaded_qps_path: PathBuf,
+ #[arg(env, default_value = "/peqp/qp")]
+ /// The path where library papers (scrapped) are stored, relative to the `static_file_storage_location`
+ library_qps_path: PathBuf,
+
+ // Server
+ #[arg(env, default_value = "8080")]
+ /// The port the server listens on
+ pub server_port: i32,
+
+ // CORS
+ #[arg(env, default_value = "https://qp.metakgp.org,http://localhost:5173")]
+ /// List of origins allowed (as a list of values separated by commas `origin1, origin2`)
+ pub cors_allowed_origins: String,
+
+ #[arg(skip)]
+ /// All paths must be handled using this
+ pub paths: Paths,
+}
+
+impl EnvVars {
+ /// Processes the environment variables after reading.
+ pub fn process(mut self) -> Result<Self, Box<dyn std::error::Error>> {
+ self.paths = Paths::new(
+ &self.static_files_url,
+ &self.static_file_storage_location,
+ &self.uploaded_qps_path,
+ &self.library_qps_path,
+ )?;
+ self.log_location = std::path::absolute(self.log_location)?;
+
+ Ok(self)
+ }
+
+ /// Returns the JWT signing key
+ pub fn get_jwt_key(&self) -> Result<Hmac<Sha256>, InvalidLength> {
+ Hmac::new_from_slice(self.jwt_secret.as_bytes())
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57
//! ### IQPS Backend
+//!
+//! The backend is divided into multiple modules. The [`routing`] module contains all the route handlers and the [`db`] module contains all database queries and models. Other modules are utilities used throughout the backend.
+
+use clap::Parser;
+use tracing_subscriber::prelude::*;
+
+mod auth;
+mod db;
+mod env;
+mod pathutils;
+mod qp;
+mod routing;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ // Read dotenv if it exists
+ if dotenvy::dotenv().is_ok() {
+ println!("Loaded an existing .env file.");
+ }
+
+ // Read environment variables
+ let env_vars = env::EnvVars::parse().process()?;
+
+ // Initialize logger
+ let (append_writer, _guard) = tracing_appender::non_blocking(tracing_appender::rolling::never(
+ env_vars
+ .log_location
+ .parent()
+ .expect("Where do you want to store that log??"),
+ env_vars
+ .log_location
+ .file_name()
+ .expect("Do you want to store the logs in a directory?"),
+ ));
+
+ let subscriber = tracing_subscriber::registry()
+ .with(
+ tracing_subscriber::fmt::layer()
+ .with_writer(append_writer)
+ .with_ansi(false),
+ )
+ .with(tracing_subscriber::fmt::layer().with_writer(std::io::stdout));
+
+ tracing::subscriber::set_global_default(subscriber)?;
+
+ // Database connection
+ let database = db::Database::new(&env_vars).await?;
+
+ // Server
+ let listener =
+ tokio::net::TcpListener::bind(format!("0.0.0.0:{}", env_vars.server_port)).await?;
+ tracing::info!("Starting server on port {}", env_vars.server_port);
+ axum::serve(listener, routing::get_router(&env_vars, database)).await?;
+
+ Ok(())
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194
//! Utils for parsing paths on the server and to store/retrieve paths from the database
+//! A "slug" is the part of the path common to the question paper and is stored in the database. Depending on the requirements, either a URL (eg: static.metakgp.org) or a path (/srv/static) can be prepended to the slug to get the final path to copy/serve/move the question paper to/from.
+
+use std::{
+ fs,
+ path::{self, Path, PathBuf},
+};
+
+use color_eyre::eyre::eyre;
+use url::Url;
+
+/// A category of papers, can also be used to represent the directory where these papers are stored
+#[allow(unused)]
+pub enum PaperCategory {
+ /// Unapproved paper
+ Unapproved,
+ /// Approved paper
+ Approved,
+ /// Library paper (scraped using the peqp scraper)
+ Library,
+}
+
+#[derive(Clone, Default)]
+/// A set of paths (absolute, relative, or even URLs) for all three categories of papers (directories)
+struct PathTriad {
+ /// Unapproved paper path
+ pub unapproved: PathBuf,
+ /// Approved paper path
+ pub approved: PathBuf,
+ /// Library paper path
+ pub library: PathBuf,
+}
+
+impl PathTriad {
+ /// Gets the path in the triad corresponding to the given paper category.
+ pub fn get(&self, category: PaperCategory) -> PathBuf {
+ match category {
+ PaperCategory::Approved => self.approved.to_owned(),
+ PaperCategory::Unapproved => self.unapproved.to_owned(),
+ PaperCategory::Library => self.library.to_owned(),
+ }
+ }
+}
+
+#[derive(Clone)]
+#[allow(unused)]
+/// Struct containing all the paths and URLs required to parse or create any question paper's slug, absolute path, or URL.
+pub struct Paths {
+ /// URL of the static files server
+ static_files_url: Url,
+ /// The absolute path to the location from where the static files server serves files
+ static_files_path: PathBuf,
+
+ /// The absolute system paths to all three directories on the server
+ system_paths: PathTriad,
+
+ /// The slugs to all three directories
+ ///
+ /// A slug is a relative path independent of the URL or system path. This slug is stored in the database and either the [`crate::pathutils::Paths::static_files_url`] or the [`crate::pathutils::Paths::static_files_path`] is prepended to it to get its URL (to send to the frontend) or the system path (for backend operations)
+ path_slugs: PathTriad,
+}
+
+impl Default for Paths {
+ fn default() -> Self {
+ Self {
+ static_files_url: Url::parse("https://metakgp.org")
+ .expect("This library thinks https://metakgp.org is not a valid URL."),
+ static_files_path: PathBuf::default(),
+ system_paths: PathTriad::default(),
+ path_slugs: PathTriad::default(),
+ }
+ }
+}
+
+#[allow(unused)]
+impl Paths {
+ /// Creates a new `Paths` struct
+ /// # Arguments
+ ///
+ /// * `static_files_url` - The static files server URL (eg: https://static.metakgp.org)
+ /// * `static_file_storage_location` - The path to the location on the server from which the static files are served (eg: /srv/static)
+ /// * `uploaded_qps_relative_path` - The path to the uploaded question papers, relative to the static files storage location. (eg: /iqps/uploaded)
+ /// * `library_qps_relative_path` - The path to the library question papers, relative to the static files storage location. (eg: /peqp/qp)
+ pub fn new(
+ static_files_url: &str,
+ static_file_storage_location: &Path,
+ uploaded_qps_relative_path: &Path,
+ library_qps_relative_path: &Path,
+ ) -> Result<Self, color_eyre::eyre::Error> {
+ // The slugs for each of the uploaded papers directories
+ let path_slugs = PathTriad {
+ // Use subdirectories `/unapproved` and `/approved` inside the uploaded qps path
+ unapproved: uploaded_qps_relative_path.join("unapproved"),
+ approved: uploaded_qps_relative_path.join("approved"),
+ library: library_qps_relative_path.to_owned(),
+ };
+
+ // The absolute system paths for each of the directories
+ let system_paths = PathTriad {
+ unapproved: path::absolute(static_file_storage_location.join(&path_slugs.unapproved))?,
+ approved: path::absolute(static_file_storage_location.join(&path_slugs.approved))?,
+ library: path::absolute(static_file_storage_location.join(&path_slugs.library))?,
+ };
+
+ // Ensure these system paths exist
+
+ // Throw error for uploaded and library paths
+ if !path::absolute(static_file_storage_location.join(uploaded_qps_relative_path))?.exists()
+ {
+ return Err(eyre!(
+ "Path for uploaded papers does not exist: {}",
+ system_paths.unapproved.to_string_lossy()
+ ));
+ }
+ if !system_paths.library.exists() {
+ return Err(eyre!(
+ "Path for library papers does not exist: {}",
+ system_paths.library.to_string_lossy()
+ ));
+ }
+
+ // Create dirs for unapproved and approved
+ if !system_paths.unapproved.exists() {
+ fs::create_dir(&system_paths.unapproved)?;
+ }
+ if !system_paths.approved.exists() {
+ fs::create_dir(&system_paths.approved)?;
+ }
+
+ Ok(Self {
+ static_files_url: Url::parse(static_files_url)?,
+ static_files_path: path::absolute(static_file_storage_location)?,
+ system_paths,
+ path_slugs,
+ })
+ }
+
+ /// Returns the slug for a given filename and paper category (directory)
+ pub fn get_slug(&self, filename: &str, category: PaperCategory) -> String {
+ self.path_slugs
+ .get(category)
+ .join(filename)
+ .to_string_lossy()
+ .to_string()
+ }
+
+ /// Returns the absolute system path for the specified directory and filename
+ pub fn get_path(&self, filename: &str, dir: PaperCategory) -> PathBuf {
+ self.system_paths.get(dir).join(filename)
+ }
+
+ /// Returns the absolute system path from a given slug
+ pub fn get_path_from_slug(&self, slug: &str) -> PathBuf {
+ self.static_files_path.join(slug)
+ }
+
+ /// Returns the static server URL for the specified directory and filename
+ pub fn get_url(
+ &self,
+ filename: &str,
+ dir: PaperCategory,
+ ) -> Result<String, color_eyre::eyre::Error> {
+ let slug = self
+ .path_slugs
+ .get(dir)
+ .join(filename)
+ .to_string_lossy()
+ .into_owned();
+
+ self.get_url_from_slug(&slug)
+ }
+
+ /// Returns the static server URL for a given slug
+ pub fn get_url_from_slug(&self, slug: &str) -> Result<String, color_eyre::eyre::Error> {
+ Ok(self.static_files_url.join(slug)?.as_str().to_string())
+ }
+
+ /// Removes any non-alphanumeric character and replaces whitespaces with `-`
+ /// Also replaces `/` with `-` and multiple spaces or hyphens will be replaced with a single one
+ pub fn sanitize_path(path: &str) -> String {
+ path.replace('/', "-") // Replace specific characters with a `-`
+ .replace('-', " ") // Convert any series of spaces and hyphens to just spaces
+ .split_whitespace() // Split at whitespaces to later replace all whitespaces with `-`
+ .map(|part| {
+ part.chars()
+ .filter(|&character| {
+ character.is_alphanumeric() || character == '-' || character == '_'
+ }) // Remove any character that is not a `-` or alphanumeric
+ .collect::<String>()
+ })
+ .collect::<Vec<String>>()
+ .join("-") // Join the parts with `-`
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166
//! Utils for parsing question paper details
+
+use color_eyre::eyre::eyre;
+use duplicate::duplicate_item;
+use serde::Serialize;
+
+use crate::env::EnvVars;
+
+#[derive(Clone, Copy)]
+/// Represents a semester.
+///
+/// It can be parsed from a [`String`] using the `.try_from()` function. An error will be returned if the given string has an invalid value.
+///
+/// This value can be converted back into a [`String`] using the [`From`] trait implementation.
+pub enum Semester {
+ /// Autumn semester, parsed from `autumn`
+ Autumn,
+ /// Spring semester, parsed from `spring`
+ Spring,
+ /// Unknown/wildcard semester, parsed from an empty string.
+ ///
+ /// Note that this is different from an invalid value and is used to represent papers for which the semester is not known. An invalid value would be `puppy` or `Hippopotomonstrosesquippedaliophobia` for example.
+ Unknown,
+}
+
+impl TryFrom<&String> for Semester {
+ type Error = color_eyre::eyre::Error;
+
+ fn try_from(value: &String) -> Result<Self, Self::Error> {
+ if value == "autumn" {
+ Ok(Semester::Autumn)
+ } else if value == "spring" {
+ Ok(Semester::Spring)
+ } else if value.is_empty() {
+ Ok(Semester::Unknown)
+ } else {
+ Err(eyre!("Error parsing semester: Invalid value."))
+ }
+ }
+}
+
+impl From<Semester> for String {
+ fn from(value: Semester) -> Self {
+ match value {
+ Semester::Autumn => "autumn".into(),
+ Semester::Spring => "spring".into(),
+ Semester::Unknown => "".into(),
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+/// Represents the exam type of the paper.
+///
+/// Can be converted to and parsed from a String using the [`From`] and [`TryFrom`] trait implementations.
+pub enum Exam {
+ /// Mid-semester examination, parsed from `midsem`
+ Midsem,
+ /// End-semester examination, parsed from `endsem`
+ Endsem,
+ /// Class test, parsed from either `ct` or `ct` followed by a number (eg: `ct1` or `ct10`).
+ ///
+ /// The optional number represents the number of the class test (eg: class test 1 or class test 21). This will be None if the number is not known, parsed from `ct`.
+ CT(Option<usize>),
+ /// Unknown class test, parsed from an empty string.
+ ///
+ /// Note that this is different from an invalid value and is used to represent papers for which the exam is not known. An invalid value would be `catto` or `metakgp` for example.
+ Unknown,
+}
+
+impl TryFrom<&String> for Exam {
+ type Error = color_eyre::eyre::Error;
+
+ fn try_from(value: &String) -> Result<Self, Self::Error> {
+ if value == "midsem" {
+ Ok(Exam::Midsem)
+ } else if value == "endsem" {
+ Ok(Exam::Endsem)
+ } else if let Some(stripped) = value.strip_prefix("ct") {
+ if stripped.is_empty() {
+ Ok(Exam::CT(None))
+ } else if let Ok(i) = stripped.parse::<usize>() {
+ Ok(Exam::CT(Some(i)))
+ } else {
+ Err(eyre!("Error parsing exam: Invalid class test number."))
+ }
+ } else if value.is_empty() {
+ Ok(Exam::Unknown)
+ } else {
+ Err(eyre!("Error parsing exam: Unknown exam type."))
+ }
+ }
+}
+
+impl From<Exam> for String {
+ fn from(value: Exam) -> Self {
+ match value {
+ Exam::Midsem => "midsem".into(),
+ Exam::Endsem => "endsem".into(),
+ Exam::Unknown => "".into(),
+ Exam::CT(None) => "ct".into(),
+ Exam::CT(Some(i)) => format!("ct{}", i),
+ }
+ }
+}
+
+#[duplicate_item(
+ ExamSem;
+ [ Exam ];
+ [ Semester ];
+)]
+impl Serialize for ExamSem {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&String::from(*self))
+ }
+}
+
+pub trait WithUrl: Sized {
+ /// Returns the question paper with the full static files URL in the `filelink` field instead of just the slug. See the [`crate::pathutils`] module for what a slug is.
+ fn with_url(self, env_vars: &EnvVars) -> Result<Self, color_eyre::eyre::Error>;
+}
+
+#[derive(Serialize, Clone)]
+/// The fields of a question paper sent from the search endpoint
+pub struct BaseQP {
+ pub id: i32,
+ pub filelink: String,
+ pub from_library: bool,
+ pub course_code: String,
+ pub course_name: String,
+ pub year: i32,
+ pub semester: Semester,
+ pub exam: Exam,
+}
+
+#[derive(Serialize, Clone)]
+/// The fields of a question paper sent from the admin dashboard endpoints.
+///
+/// This includes fields such as `approve_status` and `upload_timestamp` that would only be relevant to the dashboard.
+pub struct AdminDashboardQP {
+ #[serde(flatten)]
+ pub qp: BaseQP,
+ pub upload_timestamp: String,
+ pub approve_status: bool,
+}
+
+impl WithUrl for BaseQP {
+ fn with_url(self, env_vars: &EnvVars) -> Result<Self, color_eyre::eyre::Error> {
+ Ok(Self {
+ filelink: env_vars.paths.get_url_from_slug(&self.filelink)?,
+ ..self
+ })
+ }
+}
+
+impl WithUrl for AdminDashboardQP {
+ fn with_url(self, env_vars: &EnvVars) -> Result<Self, color_eyre::eyre::Error> {
+ Ok(Self {
+ qp: self.qp.with_url(env_vars)?,
+ ..self
+ })
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452
//! All endpoint handlers and their response types.
+//!
+//! 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)
+//!
+//! The request format is described
+
+use axum::{
+ body::Bytes,
+ extract::{Json, Multipart},
+ http::StatusCode,
+ Extension,
+};
+use color_eyre::eyre::{ContextCompat, Result};
+use http::HeaderMap;
+use serde::Serialize;
+use tokio::fs;
+
+use std::collections::HashMap;
+
+use axum::extract::{Query, State};
+use serde::Deserialize;
+
+use crate::{
+ auth::{self, Auth},
+ pathutils::PaperCategory,
+ qp::{self, AdminDashboardQP, WithUrl},
+};
+
+use super::{AppError, BackendResponse, RouterState, Status};
+
+/// The return type of a handler function. T is the data type returned if the operation was a success
+type HandlerReturn<T> = Result<(StatusCode, BackendResponse<T>), AppError>;
+
+/// Healthcheck route. Returns a `Hello World.` message if healthy.
+pub async fn healthcheck() -> HandlerReturn<()> {
+ Ok(BackendResponse::ok("Hello, World.".into(), ()))
+}
+
+/// Fetches all the unapproved papers.
+pub async fn get_unapproved(
+ State(state): State<RouterState>,
+) -> HandlerReturn<Vec<AdminDashboardQP>> {
+ let papers: Vec<AdminDashboardQP> = state.db.get_unapproved_papers().await?;
+
+ let papers = papers
+ .iter()
+ .map(|paper| paper.clone().with_url(&state.env_vars))
+ .collect::<Result<Vec<qp::AdminDashboardQP>, color_eyre::eyre::Error>>()?;
+
+ Ok(BackendResponse::ok(
+ format!("Successfully fetched {} papers.", papers.len()),
+ papers,
+ ))
+}
+
+/// Searches for question papers given a query and an optional `exam` parameter.
+///
+/// # Request Query Parameters
+/// * `query`: The query string to search in the question papers (searches course name or code)
+/// * `exam` (optional): A filter for the question paper by the exam field.
+pub async fn search(
+ State(state): State<RouterState>,
+ Query(params): Query<HashMap<String, String>>,
+) -> HandlerReturn<Vec<qp::BaseQP>> {
+ let response = if let Some(query) = params.get("query") {
+ let exam_query_str = params
+ .get("exam")
+ .map(|value| value.to_owned())
+ .unwrap_or("".into());
+
+ if let Ok(exam_filter) = (&exam_query_str).try_into() {
+ let papers = state
+ .db
+ .search_papers(query, exam_filter, exam_query_str.to_owned())
+ .await?;
+
+ let papers = papers
+ .iter()
+ .map(|paper| paper.clone().with_url(&state.env_vars))
+ .collect::<Result<Vec<qp::BaseQP>, color_eyre::eyre::Error>>()?;
+
+ Ok(BackendResponse::ok(
+ format!("Successfully fetched {} papers.", papers.len()),
+ papers,
+ ))
+ } else {
+ Ok(BackendResponse::error(
+ "Invalid `exam` URL parameter.".into(),
+ StatusCode::BAD_REQUEST,
+ ))
+ }
+ } else {
+ Ok(BackendResponse::error(
+ "`query` URL parameter is required.".into(),
+ StatusCode::BAD_REQUEST,
+ ))
+ };
+
+ response
+}
+
+#[derive(Deserialize)]
+/// The request format for the OAuth endpoint
+pub struct OAuthReq {
+ code: String,
+}
+
+#[derive(Serialize)]
+/// The response format for the OAuth endpoint
+pub struct OAuthRes {
+ token: String,
+}
+
+/// Takes a Github OAuth code and returns a JWT auth token to log in a user if authorized
+///
+/// Request format - [`OAuthReq`]
+pub async fn oauth(
+ State(state): State<RouterState>,
+ Json(body): Json<OAuthReq>,
+) -> HandlerReturn<OAuthRes> {
+ 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)]
+/// The response format for the user profile endpoint
+pub struct ProfileRes {
+ token: String,
+ username: String,
+}
+
+/// 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.
+pub async fn profile(Extension(auth): Extension<Auth>) -> HandlerReturn<ProfileRes> {
+ Ok(BackendResponse::ok(
+ "Successfully authorized the user.".into(),
+ ProfileRes {
+ token: auth.jwt,
+ username: auth.username,
+ },
+ ))
+}
+
+#[derive(Deserialize)]
+/// The request format for the paper edit endpoint
+pub struct EditReq {
+ pub id: i32,
+ pub course_code: Option<String>,
+ pub course_name: Option<String>,
+ pub year: Option<i32>,
+ pub semester: Option<String>,
+ pub exam: Option<String>,
+ pub approve_status: Option<bool>,
+}
+
+/// 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.
+///
+/// Request format - [`EditReq`]
+pub async fn edit(
+ Extension(auth): Extension<Auth>,
+ State(state): State<RouterState>,
+ Json(body): Json<EditReq>,
+) -> HandlerReturn<AdminDashboardQP> {
+ // Edit the database entry
+ let (tx, old_filelink, new_qp) = state
+ .db
+ .edit_paper(body, &auth.username, &state.env_vars)
+ .await?;
+
+ // Copy the actual file
+ let old_filepath = state.env_vars.paths.get_path_from_slug(&old_filelink);
+ let new_filepath = state.env_vars.paths.get_path_from_slug(&new_qp.qp.filelink);
+
+ if old_filepath != new_filepath {
+ if let Err(e) = fs::copy(old_filepath, new_filepath).await {
+ tracing::error!("Error copying file: {}", e);
+
+ tx.rollback().await?;
+ Ok(BackendResponse::error(
+ "Error copying question paper file.".into(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ ))
+ } else {
+ // Commit the transaction
+ tx.commit().await?;
+
+ Ok(BackendResponse::ok(
+ "Successfully updated paper details.".into(),
+ new_qp.with_url(&state.env_vars)?,
+ ))
+ }
+ } else {
+ Ok(BackendResponse::ok(
+ "Successfully updated paper details.".into(),
+ new_qp.with_url(&state.env_vars)?,
+ ))
+ }
+}
+
+#[derive(Deserialize)]
+/// The details for an uploaded question paper file
+pub struct FileDetails {
+ pub course_code: String,
+ pub course_name: String,
+ pub year: i32,
+ pub exam: String,
+ pub semester: String,
+ pub filename: String,
+}
+
+/// 10 MiB file size limit
+const FILE_SIZE_LIMIT: usize = 10 << 20;
+#[derive(Serialize)]
+/// The status of an uploaded question paper file
+pub struct UploadStatus {
+ /// The filename
+ filename: String,
+ /// Whether the file was successfully uploaded
+ status: Status,
+ /// A message describing the status
+ message: String,
+}
+
+/// Uploads question papers to the server
+///
+/// Request format - Multipart form with a `file_details` field of the format [`FileDetails`]
+pub async fn upload(
+ State(state): State<RouterState>,
+ mut multipart: Multipart,
+) -> HandlerReturn<Vec<UploadStatus>> {
+ let mut files = Vec::<(HeaderMap, Bytes)>::new();
+ let mut file_details: String = "".into();
+
+ while let Some(field) = multipart.next_field().await.unwrap() {
+ let name = field.name().unwrap().to_string();
+
+ if name == "files" {
+ files.push((field.headers().clone(), field.bytes().await?));
+ } else if name == "file_details" {
+ if file_details.is_empty() {
+ file_details = field.text().await?;
+ } else {
+ return Ok(BackendResponse::error(
+ "Error: Multiple `file_details` fields found.".into(),
+ StatusCode::BAD_REQUEST,
+ ));
+ }
+ }
+ }
+
+ let files = files;
+ let file_details: Vec<FileDetails> = serde_json::from_str(&file_details)?;
+
+ if files.len() > state.env_vars.max_upload_limit {
+ return Ok(BackendResponse::error(
+ format!(
+ "Only upto {} files can be uploaded. Found {}.",
+ state.env_vars.max_upload_limit,
+ files.len()
+ ),
+ StatusCode::BAD_REQUEST,
+ ));
+ }
+
+ if files.len() != file_details.len() {
+ return Ok(BackendResponse::error(
+ "Error: Number of files and file details array length do not match.".into(),
+ StatusCode::BAD_REQUEST,
+ ));
+ }
+
+ let files_iter = files.iter().zip(file_details.iter());
+ let mut upload_statuses = Vec::<UploadStatus>::new();
+
+ for ((file_headers, file_data), details) in files_iter {
+ let filename = details.filename.to_owned();
+
+ if file_data.len() > FILE_SIZE_LIMIT {
+ upload_statuses.push(UploadStatus {
+ filename,
+ status: Status::Error,
+ message: format!(
+ "File size too big. Only files upto {} MiB are allowed.",
+ FILE_SIZE_LIMIT >> 20
+ ),
+ });
+ continue;
+ }
+
+ if let Some(content_type) = file_headers.get("content-type") {
+ if content_type != "application/pdf" {
+ upload_statuses.push(UploadStatus {
+ filename: filename.to_owned(),
+ status: Status::Error,
+ message: "Only PDFs are supported.".into(),
+ });
+ continue;
+ }
+ } else {
+ upload_statuses.push(UploadStatus {
+ filename,
+ status: Status::Error,
+ message: "`content-type` header not found. File type could not be determined."
+ .into(),
+ });
+ continue;
+ }
+
+ // Insert the db entry
+ let (mut tx, id) = state.db.insert_new_uploaded_qp(details).await?;
+
+ // Create the new filelink (slug)
+ let filelink_slug = state
+ .env_vars
+ .paths
+ .get_slug(&format!("{}.pdf", id), PaperCategory::Unapproved);
+
+ // Update the filelink in the db
+ if state
+ .db
+ .update_uploaded_filelink(&mut tx, id, &filelink_slug)
+ .await
+ .is_ok()
+ {
+ let filepath = state.env_vars.paths.get_path_from_slug(&filelink_slug);
+
+ // Write the file data
+ if fs::write(&filepath, file_data).await.is_ok() {
+ if tx.commit().await.is_ok() {
+ upload_statuses.push(UploadStatus {
+ filename,
+ status: Status::Success,
+ message: "Succesfully uploaded file.".into(),
+ });
+ continue;
+ } else {
+ // Transaction commit failed, delete the file
+ fs::remove_file(filepath).await?;
+ upload_statuses.push(UploadStatus {
+ filename,
+ status: Status::Success,
+ message: "Succesfully uploaded file.".into(),
+ });
+ continue;
+ }
+ } else {
+ tx.rollback().await?;
+ }
+
+ // If the write fails, rollback the transaction, else commit it.
+ } else {
+ tx.rollback().await?;
+
+ upload_statuses.push(UploadStatus {
+ filename,
+ status: Status::Error,
+ message: "Error updating the filelink".into(),
+ });
+ continue;
+ }
+
+ upload_statuses.push(UploadStatus {
+ filename,
+ status: Status::Error,
+ message: "THIS SHOULD NEVER HAPPEN. REPORT IMMEDIATELY. ALSO THIS WOULDN'T HAPPEN IF RUST HAD STABLE ASYNC CLOSURES.".into(),
+ });
+ }
+
+ Ok(BackendResponse::ok(
+ format!("Successfully processed {} files", upload_statuses.len()),
+ upload_statuses,
+ ))
+}
+
+#[derive(Deserialize)]
+/// The request format for the delete endpoint
+pub struct DeleteReq {
+ id: i32,
+}
+
+/// Deletes a given paper. Library papers cannot be deleted.
+///
+/// Request format - [`DeleteReq`]
+pub async fn delete(
+ State(state): State<RouterState>,
+ Json(body): Json<DeleteReq>,
+) -> HandlerReturn<()> {
+ let paper_deleted = state.db.soft_delete(body.id).await?;
+
+ if paper_deleted {
+ Ok(BackendResponse::ok(
+ "Succesfully deleted the paper.".into(),
+ (),
+ ))
+ } else {
+ Ok(BackendResponse::error(
+ "No paper was changed. Either the paper does not exist, is a library paper (cannot be deleted), or is already deleted.".into(),
+ StatusCode::BAD_REQUEST,
+ ))
+ }
+}
+
+/// Fetches all question papers that match one or more properties specified. `course_name` is compulsory.
+///
+/// # Request Query Parameters
+/// * `course_code`: The course code of the question paper. (required)
+/// * `year` (optional): The year of the question paper.
+/// * `course_name` (optional): The course name (exact).
+/// * `semester` (optional): The semester (autumn/spring)
+/// * `exam` (optional): The exam field (midsem/endsem/ct)
+pub async fn similar(
+ State(state): State<RouterState>,
+ Query(body): Query<HashMap<String, String>>,
+) -> HandlerReturn<Vec<AdminDashboardQP>> {
+ if !body.contains_key("course_code") {
+ return Ok(BackendResponse::error(
+ "Error: `course_code` is required.".into(),
+ StatusCode::BAD_REQUEST,
+ ));
+ }
+
+ let papers = state
+ .db
+ .get_similar_papers(
+ body.get("course_code")
+ .context("Expected course code to be here.")?,
+ body.get("year")
+ .map(|year| year.parse::<i32>())
+ .transpose()?,
+ body.get("semester"),
+ body.get("exam"),
+ )
+ .await?;
+
+ Ok(BackendResponse::ok(
+ format!("Found {} similar papers.", papers.len()),
+ papers
+ .iter()
+ .map(|paper| paper.to_owned().with_url(&state.env_vars))
+ .collect::<Result<Vec<AdminDashboardQP>>>()?,
+ ))
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50
//! Middleware for the axum router
+
+use axum::{
+ extract::{Request, State},
+ middleware::Next,
+ response::{IntoResponse, Response},
+};
+use http::{HeaderMap, StatusCode};
+
+use crate::auth;
+
+use super::{AppError, BackendResponse, RouterState};
+
+/// Verifies the JWT and authenticates a user. If the JWT is invalid, the user is sent an unauthorized status code. If the JWT is valid, the authentication is added to the state.
+pub async fn verify_jwt_middleware(
+ State(state): State<RouterState>,
+ headers: HeaderMap,
+ mut request: Request,
+ next: Next,
+) -> Result<Response, AppError> {
+ if let Some(auth_header) = headers.get("Authorization") {
+ if let Some(jwt) = auth_header.to_str()?.strip_prefix("Bearer ") {
+ let auth = auth::verify_token(jwt, &state.env_vars).await;
+
+ if let Ok(auth) = auth {
+ // If auth is fine, add it to the request extensions
+ request.extensions_mut().insert(auth);
+ Ok(next.run(request).await)
+ } else {
+ Ok(BackendResponse::<()>::error(
+ "Authorization token invalid.".into(),
+ StatusCode::UNAUTHORIZED,
+ )
+ .into_response())
+ }
+ } else {
+ Ok(BackendResponse::<()>::error(
+ "Authorization header format invalid.".into(),
+ StatusCode::UNAUTHORIZED,
+ )
+ .into_response())
+ }
+ } else {
+ Ok(BackendResponse::<()>::error(
+ "Authorization header missing.".into(),
+ StatusCode::UNAUTHORIZED,
+ )
+ .into_response())
+ }
+}
+
+1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169
//! Router, [`handlers`], [`middleware`], state, and response utils.
+
+use axum::{
+ extract::{DefaultBodyLimit, Json},
+ http::StatusCode,
+ response::IntoResponse,
+};
+use http::{HeaderValue, Method};
+use serde::Serialize;
+use tower_http::{
+ cors::{Any, CorsLayer},
+ trace::{self, TraceLayer},
+};
+
+use crate::{
+ db::{self, Database},
+ env::EnvVars,
+};
+
+mod handlers;
+mod middleware;
+
+pub use handlers::{EditReq, FileDetails};
+
+/// Returns the Axum router for IQPS
+pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router {
+ let state = RouterState {
+ db,
+ env_vars: env_vars.clone(),
+ };
+
+ axum::Router::new()
+ .route("/unapproved", axum::routing::get(handlers::get_unapproved))
+ .route("/profile", axum::routing::get(handlers::profile))
+ .route("/edit", axum::routing::post(handlers::edit))
+ .route("/delete", axum::routing::post(handlers::delete))
+ .route("/similar", axum::routing::get(handlers::similar))
+ .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))
+ .layer(DefaultBodyLimit::max(2 << 20)) // Default limit of 2 MiB
+ .route("/upload", axum::routing::post(handlers::upload))
+ .layer(DefaultBodyLimit::max(50 << 20)) // 50 MiB limit for upload endpoint
+ .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::<HeaderValue>()
+ .expect("CORS Allowed Origins Invalid")
+ })
+ .collect::<Vec<HeaderValue>>(),
+ ),
+ )
+}
+
+#[derive(Clone)]
+/// The state of the axum router, containing the environment variables and the database connection.
+struct RouterState {
+ pub db: db::Database,
+ pub env_vars: EnvVars,
+}
+
+#[derive(Clone, Copy)]
+/// The status of a server response
+enum Status {
+ Success,
+ Error,
+}
+
+impl From<Status> for String {
+ fn from(value: Status) -> Self {
+ match value {
+ Status::Success => "success".into(),
+ Status::Error => "error".into(),
+ }
+ }
+}
+
+impl Serialize for Status {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&String::from(*self))
+ }
+}
+
+/// Standard backend response format (serialized as JSON)
+#[derive(serde::Serialize)]
+struct BackendResponse<T: Serialize> {
+ /// Whether the operation succeeded or failed
+ pub status: Status,
+ /// A message describing the state of the operation (success/failure message)
+ pub message: String,
+ /// Any optional data sent (only sent if the operation was a success)
+ pub data: Option<T>,
+}
+
+impl<T: serde::Serialize> BackendResponse<T> {
+ /// Creates a new success backend response with the given message and data
+ pub fn ok(message: String, data: T) -> (StatusCode, Self) {
+ (
+ StatusCode::OK,
+ Self {
+ status: Status::Success,
+ message,
+ data: Some(data),
+ },
+ )
+ }
+
+ /// Creates a new error backend response with the given message, data, and an HTTP status code
+ pub fn error(message: String, status_code: StatusCode) -> (StatusCode, Self) {
+ (
+ status_code,
+ Self {
+ status: Status::Error,
+ message,
+ data: None,
+ },
+ )
+ }
+}
+
+impl<T: Serialize> IntoResponse for BackendResponse<T> {
+ fn into_response(self) -> axum::response::Response {
+ Json(self).into_response()
+ }
+}
+
+/// A struct representing the error returned by a handler. This is automatically serialized into JSON and sent as an internal server error (500) backend response. The `?` operator can be used anywhere inside a handler to do so.
+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<E> From<E> for AppError
+where
+ E: Into<color_eyre::eyre::Error>,
+{
+ fn from(err: E) -> Self {
+ Self(err.into())
+ }
+}
+
fn:
) to \
+ restrict the search to a given item kind.","Accepted kinds are: fn
, mod
, struct
, \
+ enum
, trait
, type
, macro
, \
+ and const
.","Search functions by type signature (e.g., vec -> usize
or \
+ -> vec
or String, enum:Cow -> bool
)","You can look for items with an exact name by putting double quotes around \
+ your request: \"string\"
","Look for functions that accept or return \
+ slices and \
+ arrays by writing \
+ square brackets (e.g., -> [u8]
or [] -> Option
)","Look for items inside another one by searching for a path: vec::Vec
",].map(x=>""+x+"
").join("");const div_infos=document.createElement("div");addClass(div_infos,"infos");div_infos.innerHTML="${value.replaceAll(" ", " ")}
`}else{error[index]=value}});output+=`