diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4d9636b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.showUnlinkedFileNotification": false +} \ No newline at end of file diff --git a/documentation/not_found.html b/documentation/not_found.html new file mode 100644 index 0000000..446e782 --- /dev/null +++ b/documentation/not_found.html @@ -0,0 +1,26 @@ + + + + + + + + +

The content you are looking for does not appear to be here...

+ + + \ No newline at end of file diff --git a/public/Error/not_found.html b/public/Error/not_found.html new file mode 100644 index 0000000..ff4db94 --- /dev/null +++ b/public/Error/not_found.html @@ -0,0 +1,12 @@ + + + + + + Not Found + + + The content you are looking for does not appear to be here... + + + \ No newline at end of file diff --git a/src/typing/leaderboard.rs b/src/api/leaderboard.rs similarity index 55% rename from src/typing/leaderboard.rs rename to src/api/leaderboard.rs index 6e808d2..c0d18e3 100644 --- a/src/typing/leaderboard.rs +++ b/src/api/leaderboard.rs @@ -1,8 +1,5 @@ -use rocket::{ serde::json::Json, State }; -use crate::typing::sql::{ - Database, - LeaderBoardTest -}; +use crate::api::sql::{Database, LeaderBoardTest}; +use rocket::{serde::json::Json, State}; type LeaderBoardTests = Vec; @@ -10,15 +7,14 @@ type LeaderBoardTests = Vec; /// a json array /// Acessible from http://url/api/leaderboard #[get("/leaderboard")] -pub async fn leaderboard( database: &State ) -> Option> { +pub async fn leaderboard(database: &State) -> Option> { let leaderboard = match database.get_leaderboard(0).await { - Err(why) => { + Err(why) => { println!("Error getting leaderboard, {why}"); - return None + return None; } - Ok(leaderboard) => { leaderboard } + Ok(leaderboard) => leaderboard, }; Some(Json(leaderboard)) } - diff --git a/src/typing/mod.rs b/src/api/mod.rs similarity index 77% rename from src/typing/mod.rs rename to src/api/mod.rs index 7c3efda..0bda601 100644 --- a/src/typing/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,4 @@ -pub mod sql; -pub mod user; pub mod leaderboard; -pub mod test; \ No newline at end of file +pub mod sql; +pub mod test; +pub mod user; diff --git a/src/typing/sql.rs b/src/api/sql.rs similarity index 52% rename from src/typing/sql.rs rename to src/api/sql.rs index 022107c..c264175 100644 --- a/src/typing/sql.rs +++ b/src/api/sql.rs @@ -4,16 +4,16 @@ //! it abstracts away the rusqlite necessary to perform //! these functions //! Author: Arlo Filley -//! -//! TODO: +//! +//! TODO: //! - put necessary structs into a different file -//! - create structure for the input of post test +//! - create structure for the input of post test // Imports for json handling and rusqlite -use sqlx::sqlite::{ SqlitePool, SqlitePoolOptions }; -use rocket::serde::{ Serialize, json::Json }; +use rocket::serde::{json::Json, Serialize}; +use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; -use crate::typing::test::PostTest; +use crate::api::test::PostTest; /// Contains the database connection pool pub struct Database(SqlitePool); @@ -24,7 +24,7 @@ impl Database { pub async fn new() -> Result { let pool = SqlitePoolOptions::new() .max_connections(2) - .connect("sqlite:/Users/arlo/code/cs_coursework/database/dev/database.sqlite") + .connect("sqlite:/Users/arlo/Code/Projects/cs_coursework/database/database.sqlite") .await?; Ok(Self(pool)) @@ -33,16 +33,20 @@ impl Database { /// Creates the necessary tables inside the database with /// correct normalised links between data for later querying pub async fn _new_database(&self) -> Result<(), sqlx::Error> { - sqlx::query!(" + sqlx::query!( + " CREATE TABLE IF NOT EXISTS Users ( user_id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, secret TEXT NOT NULL )" - ).execute(&self.0).await?; + ) + .execute(&self.0) + .await?; - sqlx::query!(" + sqlx::query!( + " CREATE TABLE IF NOT EXISTS Tests ( test_id INTEGER PRIMARY KEY, test_type TEXT NOT NULL, @@ -55,7 +59,9 @@ impl Database { user_id INTEGER, FOREIGN KEY(user_id) REFERENCES users(user_id) )" - ).execute(&self.0).await?; + ) + .execute(&self.0) + .await?; Ok(()) } @@ -64,15 +70,18 @@ impl Database { /// a database record with the data pub async fn create_test(&self, test: Json>) -> Result<(), sqlx::Error> { // Test to see whether the secret is correct - let user = sqlx::query!(" + let user = sqlx::query!( + " Select secret From Users - Where user_id=?", + Where user_id=?", test.user_id - ).fetch_one(&self.0).await?; + ) + .fetch_one(&self.0) + .await?; if user.secret != test.secret { - return Err(sqlx::Error::RowNotFound) + return Err(sqlx::Error::RowNotFound); } sqlx::query!(" @@ -86,25 +95,43 @@ impl Database { /// takes a username and password and creates a database /// entry for a new user - pub async fn create_user(&self, username: &str, password: &str, secret: &str) -> Result<(), sqlx::Error> { - sqlx::query!(" + pub async fn create_user( + &self, + username: &str, + password: &str, + secret: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " INSERT INTO Users (username, password, secret) - VALUES (?1, ?2, ?3)", - username, password, secret - ).execute(&self.0).await?; + VALUES (?1, ?2, ?3)", + username, + password, + secret + ) + .execute(&self.0) + .await?; Ok(()) } /// takes a username and password as inputs and returns the /// user_id and secret of the user if one exists - pub async fn find_user(&self, username: &str, password: &str) -> Result, sqlx::Error> { - let user = sqlx::query!(" + pub async fn find_user( + &self, + username: &str, + password: &str, + ) -> Result, sqlx::Error> { + let user = sqlx::query!( + " SELECT user_id, secret FROM Users WHERE username=? AND password=?", - username, password - ).fetch_one(&self.0).await?; + username, + password + ) + .fetch_one(&self.0) + .await?; let user_id = user.user_id.unwrap() as u32; let secret = user.secret.clone(); @@ -114,51 +141,111 @@ impl Database { /// returns all the tests that a given user_id has /// completed from the database - pub async fn get_user_tests(&self, user_id: u32, secret: &str) -> Result, sqlx::Error> { - let tests = sqlx::query!(" + pub async fn get_user_tests( + &self, + user_id: u32, + secret: &str, + ) -> Result, sqlx::Error> { + let tests = sqlx::query!( + " SELECT test_type, test_length, test_time, test_seed, quote_id, wpm, accuracy FROM tests INNER JOIN users ON users.user_id = tests.user_id WHERE users.user_id=? AND users.secret=?", - user_id, secret - ).fetch_all(&self.0).await?; + user_id, + secret + ) + .fetch_all(&self.0) + .await?; println!("{}", tests.len()); - let user_tests = tests.iter() - .map(|test| Test { - test_type: test.test_type.clone(), - test_length: test.test_length.unwrap() as u32, - test_time: test.test_time.unwrap() as u32, - test_seed: test.test_seed.unwrap(), - quote_id: test.quote_id.unwrap() as i32, - wpm: test.wpm.unwrap() as u8, - accuracy: test.accuracy.unwrap() as u8 + let user_tests = tests + .iter() + .map(|test| Test { + test_type: test.test_type.clone(), + test_length: test.test_length.unwrap() as u32, + test_time: test.test_time.unwrap() as u32, + test_seed: test.test_seed.unwrap(), + quote_id: test.quote_id.unwrap() as i32, + wpm: test.wpm.unwrap() as u8, + accuracy: test.accuracy.unwrap() as u8, }) .collect(); Ok(user_tests) } + /// returns a vector of leaderboard tests, where each one is the fastest words /// per minute that a given user has achieved - pub async fn get_leaderboard(&self, _user_id: u32) -> Result, sqlx::Error> { + pub async fn get_leaderboard( + &self, + _user_id: u32, + ) -> Result, sqlx::Error> { let tests = sqlx::query!( "SELECT users.username, tests.wpm FROM tests INNER JOIN users ON users.user_id = tests.user_id GROUP BY users.username ORDER BY tests.wpm DESC", - ).fetch_all(&self.0).await?; + ) + .fetch_all(&self.0) + .await?; - let leaderboard_tests = tests.iter() - .map(|test| LeaderBoardTest { - username: test.username.clone(), - wpm: test.wpm.unwrap() as u8 + let leaderboard_tests = tests + .iter() + .map(|test| LeaderBoardTest { + username: test.username.clone(), + wpm: test.wpm.unwrap() as u8, }) .collect(); Ok(leaderboard_tests) } + + /// Authenticates a user based on their user ID and secret. + /// + /// # Arguments + /// + /// * `user_id` - The ID of the user to authenticate. + /// * `secret` - The secret associated with the user. + /// + /// # Returns + /// + /// Returns a `Result` indicating whether the authentication was successful or not. + /// - `Ok(true)` if authentication is successful. + /// - `Ok(false)` if authentication fails. + /// - `Err` if there's an error accessing the database. + /// + /// # Examples + /// + /// ```rust + /// use crate::sql::Database; + /// + /// #[tokio::main] + /// async fn main() { + /// let db = Database::new().await.expect("Failed to create database connection"); + /// + /// // Authenticate user with user ID 123 and secret "example_secret" + /// let authenticated = db.authenticate_user(123, "example_secret").await; + /// assert_eq!(authenticated, Ok(true)); + /// } + /// ``` + pub async fn authenticate_user(&self, user_id: u32, secret: &str) -> Result { + // Test to see whether the secret is correct + let user = sqlx::query!( + " + Select secret + From Users + Where user_id=?", + user_id + ) + .fetch_one(&self.0) + .await?; + + // Compare the fetched secret with the provided one + Ok(user.secret == secret) + } } /// struct representing data that needs to be sent @@ -182,4 +269,4 @@ pub struct Test { pub struct LeaderBoardTest { username: String, wpm: u8, -} \ No newline at end of file +} diff --git a/src/typing/test.rs b/src/api/test.rs similarity index 75% rename from src/typing/test.rs rename to src/api/test.rs index 944182c..108c281 100644 --- a/src/typing/test.rs +++ b/src/api/test.rs @@ -1,16 +1,10 @@ -use crate::typing::sql::Database; +use crate::api::sql::Database; +use rand::{rngs::ThreadRng, Rng}; use rocket::{ - serde::{ Deserialize, json::Json }, - State -}; -use rand::{ - Rng, - rngs::ThreadRng -}; -use std::{ - fs, - vec, + serde::{json::Json, Deserialize}, + State, }; +use std::{fs, vec}; /// the datascructure that the webserver will recieve /// when a post is made to the http://url/api/post_test route @@ -25,7 +19,7 @@ pub struct PostTest<'r> { pub wpm: u8, pub accuracy: u8, pub user_id: u32, - pub secret: &'r str + pub secret: &'r str, } /// Api Route that accepts test data and posts it to the database @@ -34,8 +28,12 @@ pub struct PostTest<'r> { pub async fn create_test(test: Json>, database: &State) { let user_id = test.user_id; match database.create_test(test).await { - Err(why) => { println!("A database error occured creating a test, {why}"); } - Ok(()) => { println!("Successfully created test for {user_id}"); } + Err(why) => { + println!("A database error occured creating a test, {why}"); + } + Ok(()) => { + println!("Successfully created test for {user_id}"); + } } } @@ -43,7 +41,7 @@ pub async fn create_test(test: Json>, database: &State) { /// Accessible from http://url/api/get_test #[get("/new_test")] pub fn new_test() -> Json> { - let mut word_vec: Vec<&str> = vec![]; + let mut word_vec: Vec<&str> = vec![]; let words: String = fs::read_to_string("wordlist.txt").unwrap(); for word in words.split('\n') { word_vec.push(word); @@ -58,4 +56,4 @@ pub fn new_test() -> Json> { } Json(return_list.clone()) -} \ No newline at end of file +} diff --git a/src/api/user.rs b/src/api/user.rs new file mode 100644 index 0000000..637d4e8 --- /dev/null +++ b/src/api/user.rs @@ -0,0 +1,148 @@ +use rocket::{ + http::Status, + serde::{json::Json, Deserialize, Serialize}, + State, +}; + +use rand::{distributions::Alphanumeric, Rng}; + +use crate::api::sql::{Database, Test}; + +/// Struct representing the user +#[derive(Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct User<'r> { + username: &'r str, + password: &'r str, +} + +/// Route takes data about the user as a struct +/// and then creates the user in the database +/// Acessible from http://url/api/create_user +#[post("/create_user", data = "")] +pub async fn sign_up(user: Json>, database: &State) { + let secret: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(50) + .map(char::from) + .collect(); + + match database + .create_user(user.username, &sha256::digest(user.password), &secret) + .await + { + Err(why) => { + println!("A database error occured during signup, {why}"); + } + Ok(()) => { + println!("Succesfully Signed up User: {}", user.username); + } + } +} + +/// Retrieves tests associated with a specific user from the database and returns them as a JSON array. +/// +/// # Endpoint +/// +/// GET /api/get_tests// +/// +/// # Path Parameters +/// +/// - `user_id`: User ID of the user whose tests need to be retrieved. +/// - `secret`: Secret key for authentication. +/// +/// # Returns +/// +/// Returns a JSON array containing the user's tests if the user is authenticated and the tests are found. +/// +/// If the user authentication fails, returns a `401 Unauthorized` status. +/// +/// If the tests are not found or any database-related error occurs, returns a `404 Not Found` status. +/// +/// # Example Request +/// +/// ```bash +/// curl -X GET "https://example.com/api/get_tests/123/your_secret_key_here" +/// ``` +/// +/// # Example Response +/// +/// ```json +/// [ +/// { +/// "test_type": "typing", +/// "test_length": 100, +/// "test_time": 300, +/// "test_seed": 987654321, +/// "quote_id": 123, +/// "wpm": 65, +/// "accuracy": 98 +/// }, +/// { +/// "test_type": "multiple_choice", +/// "test_length": 50, +/// "test_time": 150, +/// "test_seed": 123456789, +/// "quote_id": null, +/// "wpm": null, +/// "accuracy": 85 +/// } +/// ] +/// ``` + +#[get("/get_tests//")] +pub async fn get_tests(user_id: u32, secret: &str, database: &State) -> Result>, Status> { + match database.authenticate_user(user_id, &secret).await { + Err(_) => return Err(Status::InternalServerError), + Ok(authenticated) => { + if !authenticated { + return Err(Status::Unauthorized); + } + } + } + + match database.get_user_tests(user_id, &secret).await { + Err(why) => { + println!("A database error occured during getting_tests, {why}"); + Err(Status::NotFound) + } + Ok(tests) => { + println!("Succesfully Found Tests for User {user_id}"); + Ok(Json(tests)) + } + } +} + +/// takes the users login information and returns the users user id +/// which can be used to identify their tests etc. +/// Accessible from http://url/api/login +#[get("/login//")] +pub async fn login( + username: &str, + password: &str, + database: &State, +) -> Json> { + match database + .find_user(username, &sha256::digest(password)) + .await + { + Err(why) => { + println!("A database error occured during login for {username}, {why}"); + Json(None) + } + Ok(user) => match user { + None => Json(None), + Some(user) => Json(Some(LoginResponse { + user_id: user.0, + secret: user.1, + })), + }, + } +} + +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +pub struct LoginResponse { + user_id: u32, + secret: String, +} diff --git a/src/catchers/mod.rs b/src/catchers/mod.rs new file mode 100644 index 0000000..aca64f7 --- /dev/null +++ b/src/catchers/mod.rs @@ -0,0 +1 @@ +pub mod not_found; \ No newline at end of file diff --git a/src/catchers/not_found.rs b/src/catchers/not_found.rs new file mode 100644 index 0000000..d4f1bb7 --- /dev/null +++ b/src/catchers/not_found.rs @@ -0,0 +1,16 @@ +use rocket::{response::content::RawHtml, Request}; + +#[catch(404)] +pub fn api_not_found(req: &Request) -> String { + format!("Sorry, '{}' couldn't be found.", req.uri()) +} + +#[catch(404)] +pub fn frontend_not_found<'a>(_req: &Request) -> RawHtml<&'a str> { + RawHtml(include_str!("../../public/Error/not_found.html")) +} + +#[catch(404)] +pub fn documentation_not_found<'a>(_req: &Request) -> RawHtml<&'a str> { + RawHtml(include_str!("../../documentation/not_found.html")) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0628e1e..be6e8aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,12 +40,17 @@ use rocket::{ Build, Rocket, }; -mod typing; +mod api; +mod catchers; -use crate::typing::leaderboard::leaderboard; -use crate::typing::sql::Database; -use crate::typing::test::{create_test, new_test}; -use crate::typing::user::{get_tests, login, sign_up}; +use crate::api::leaderboard::leaderboard; +use crate::api::sql::Database; +use crate::api::test::{create_test, new_test}; +use crate::api::user::{get_tests, login, sign_up}; + +use catchers::not_found::api_not_found; +use catchers::not_found::frontend_not_found; +use catchers::not_found::documentation_not_found; // Imports for sql, see sql.rs for more information @@ -66,6 +71,8 @@ async fn rocket() -> Rocket { .mount("/test", routes![test]) // hosts the api routes necessary for the website // to interact with the database + .mount("/api/documentation", FileServer::from(relative!("documentation"))) + .register("/api/documentation", catchers![documentation_not_found]) .mount( "/api", routes![ @@ -77,7 +84,11 @@ async fn rocket() -> Rocket { new_test, ], ) + .register("/api", catchers![api_not_found]) + + // hosts the fileserver - .mount("/typing", FileServer::from(relative!("websites/Typing"))) + .mount("/typing", FileServer::from(relative!("public"))) + .register("/typing", catchers![frontend_not_found]) .manage(Database::new().await.unwrap()) } diff --git a/src/typing/user.rs b/src/typing/user.rs deleted file mode 100644 index 8006171..0000000 --- a/src/typing/user.rs +++ /dev/null @@ -1,76 +0,0 @@ -use rocket::{ - serde::{Deserialize, json::Json, Serialize}, - State -}; - -use rand::{ distributions::Alphanumeric, Rng }; - -use crate::typing::sql::{ Database, Test }; - -/// Struct representing the user -#[derive(Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct User<'r> { - username: &'r str, - password: &'r str -} - -/// Route takes data about the user as a struct -/// and then creates the user in the database -/// Acessible from http://url/api/create_user -#[post("/create_user", data = "")] -pub async fn sign_up(user: Json>, database: &State) { - let secret: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(50) - .map(char::from) - .collect(); - - match database.create_user( user.username, &sha256::digest(user.password), &secret ).await { - Err(why) => { println!("A database error occured during signup, {why}"); } - Ok(()) => { println!("Succesfully Signed up User: {}", user.username); } - } -} - -/// Gets the users tests from the database and returns it as a -/// json array. -/// Accessible from http://url/api/get_user_tests -#[get("/get_tests//")] -pub async fn get_tests(user_id: u32, secret: String, database: &State) -> Option>> { - match database.get_user_tests(user_id, &secret).await { - Err(why) => { - println!("A database error occured during getting_tests, {why}"); - None - } - Ok(tests) => { - println!("Succesfully Found Tests for User {user_id}"); - Some(Json(tests)) - } - } -} - -/// takes the users login information and returns the users user id -/// which can be used to identify their tests etc. -/// Accessible from http://url/api/login -#[get("/login//")] -pub async fn login(username: &str, password: &str, database: &State) -> Json> { - match database.find_user(username, &sha256::digest(password)).await { - Err(why) => { - println!("A database error occured during login for {username}, {why}"); - Json(None) - } - Ok(user) => { - match user { - None => Json(None), - Some(user) => { Json(Some(LoginResponse { user_id: user.0, secret: user.1 })) } - } - } - } -} - -#[derive(Serialize)] -#[serde(crate = "rocket::serde")] -pub struct LoginResponse { - user_id: u32, - secret: String, -}