Merge branch 'dev'
This commit is contained in:
		
						commit
						419b50024f
					
				
					 15 changed files with 287 additions and 251 deletions
				
			
		|  | @ -3,7 +3,10 @@ | ||||||
| 
 | 
 | ||||||
| # Server files | # Server files | ||||||
| /server/target | /server/target | ||||||
|  | **/target | ||||||
| 
 | 
 | ||||||
| # Client files | # Client files | ||||||
| /client/node_modules | /client/node_modules | ||||||
| /client/dist | /client/dist | ||||||
|  | **/node_modules | ||||||
|  | **/dist | ||||||
							
								
								
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -22,3 +22,9 @@ dist-ssr | ||||||
| *.njsproj | *.njsproj | ||||||
| *.sln | *.sln | ||||||
| *.sw? | *.sw? | ||||||
|  | 
 | ||||||
|  | *.env | ||||||
|  | *.db* | ||||||
|  | 
 | ||||||
|  | # Remove this if ever needed | ||||||
|  | *.sqlx | ||||||
|  |  | ||||||
|  | @ -20,20 +20,20 @@ export interface Post extends NewPost { | ||||||
| 
 | 
 | ||||||
| export async function getPosts(): Promise<Post[]> { | export async function getPosts(): Promise<Post[]> { | ||||||
| //   const res = await fetch(`${API_URL}/posts`);
 | //   const res = await fetch(`${API_URL}/posts`);
 | ||||||
|   const res = await fetch("/api/"); |   const res = await fetch("/api/posts"); | ||||||
|   const data = await res.json(); |   const data = await res.json(); | ||||||
|   return data; |   return data; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getPost(id: string): Promise<Post> { | export async function getPost(id: string): Promise<Post> { | ||||||
|   const res = await fetch(`/api/${id}`); |   const res = await fetch(`/api/posts/${id}`); | ||||||
|   const data = await res.json(); |   const data = await res.json(); | ||||||
|   return data; |   return data; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function createPost(post: NewPost): Promise<void> { | export async function createPost(post: NewPost): Promise<void> { | ||||||
| //   await fetch(`${API_URL}`, {
 | //   await fetch(`${API_URL}`, {
 | ||||||
|   await fetch("/api/", { |   await fetch("/api/posts", { | ||||||
|     method: "POST", |     method: "POST", | ||||||
|     headers: { |     headers: { | ||||||
|       "Content-Type": "application/json", |       "Content-Type": "application/json", | ||||||
|  |  | ||||||
							
								
								
									
										10
									
								
								server/Cargo.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								server/Cargo.lock
									
										
									
										generated
									
									
									
								
							|  | @ -1782,6 +1782,7 @@ dependencies = [ | ||||||
|  "argon2", |  "argon2", | ||||||
|  "chrono", |  "chrono", | ||||||
|  "clap", |  "clap", | ||||||
|  |  "dotenvy", | ||||||
|  "env_logger", |  "env_logger", | ||||||
|  "jsonwebtoken", |  "jsonwebtoken", | ||||||
|  "log", |  "log", | ||||||
|  | @ -1945,6 +1946,7 @@ dependencies = [ | ||||||
|  "atoi", |  "atoi", | ||||||
|  "byteorder", |  "byteorder", | ||||||
|  "bytes", |  "bytes", | ||||||
|  |  "chrono", | ||||||
|  "crc", |  "crc", | ||||||
|  "crossbeam-queue", |  "crossbeam-queue", | ||||||
|  "dotenvy", |  "dotenvy", | ||||||
|  | @ -1973,6 +1975,7 @@ dependencies = [ | ||||||
|  "tokio-stream", |  "tokio-stream", | ||||||
|  "tracing", |  "tracing", | ||||||
|  "url", |  "url", | ||||||
|  |  "uuid", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -2006,6 +2009,7 @@ dependencies = [ | ||||||
|  "sha2", |  "sha2", | ||||||
|  "sqlx-core", |  "sqlx-core", | ||||||
|  "sqlx-mysql", |  "sqlx-mysql", | ||||||
|  |  "sqlx-postgres", | ||||||
|  "sqlx-sqlite", |  "sqlx-sqlite", | ||||||
|  "syn 1.0.109", |  "syn 1.0.109", | ||||||
|  "tempfile", |  "tempfile", | ||||||
|  | @ -2024,6 +2028,7 @@ dependencies = [ | ||||||
|  "bitflags 2.4.0", |  "bitflags 2.4.0", | ||||||
|  "byteorder", |  "byteorder", | ||||||
|  "bytes", |  "bytes", | ||||||
|  |  "chrono", | ||||||
|  "crc", |  "crc", | ||||||
|  "digest", |  "digest", | ||||||
|  "dotenvy", |  "dotenvy", | ||||||
|  | @ -2052,6 +2057,7 @@ dependencies = [ | ||||||
|  "stringprep", |  "stringprep", | ||||||
|  "thiserror", |  "thiserror", | ||||||
|  "tracing", |  "tracing", | ||||||
|  |  "uuid", | ||||||
|  "whoami", |  "whoami", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | @ -2065,6 +2071,7 @@ dependencies = [ | ||||||
|  "base64 0.21.4", |  "base64 0.21.4", | ||||||
|  "bitflags 2.4.0", |  "bitflags 2.4.0", | ||||||
|  "byteorder", |  "byteorder", | ||||||
|  |  "chrono", | ||||||
|  "crc", |  "crc", | ||||||
|  "dotenvy", |  "dotenvy", | ||||||
|  "etcetera", |  "etcetera", | ||||||
|  | @ -2091,6 +2098,7 @@ dependencies = [ | ||||||
|  "stringprep", |  "stringprep", | ||||||
|  "thiserror", |  "thiserror", | ||||||
|  "tracing", |  "tracing", | ||||||
|  |  "uuid", | ||||||
|  "whoami", |  "whoami", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | @ -2101,6 +2109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "d59dc83cf45d89c555a577694534fcd1b55c545a816c816ce51f20bbe56a4f3f" | checksum = "d59dc83cf45d89c555a577694534fcd1b55c545a816c816ce51f20bbe56a4f3f" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "atoi", |  "atoi", | ||||||
|  |  "chrono", | ||||||
|  "flume", |  "flume", | ||||||
|  "futures-channel", |  "futures-channel", | ||||||
|  "futures-core", |  "futures-core", | ||||||
|  | @ -2114,6 +2123,7 @@ dependencies = [ | ||||||
|  "sqlx-core", |  "sqlx-core", | ||||||
|  "tracing", |  "tracing", | ||||||
|  "url", |  "url", | ||||||
|  |  "uuid", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  |  | ||||||
|  | @ -11,11 +11,15 @@ actix-web = "4.4.0" | ||||||
| argon2 = { version = "0.5.2", features = ["zeroize"] } | argon2 = { version = "0.5.2", features = ["zeroize"] } | ||||||
| chrono = { version = "0.4.31", features = ["serde"] } | chrono = { version = "0.4.31", features = ["serde"] } | ||||||
| clap = { version = "4.4.5", features = ["derive"] } | clap = { version = "4.4.5", features = ["derive"] } | ||||||
|  | dotenvy = "0.15.7" | ||||||
| env_logger = "0.10.0" | env_logger = "0.10.0" | ||||||
| jsonwebtoken = "8.3.0" | jsonwebtoken = "8.3.0" | ||||||
| log = "0.4.20" | log = "0.4.20" | ||||||
| serde = { version = "1.0.188", features = ["derive"] } | serde = { version = "1.0.188", features = ["derive"] } | ||||||
| serde_json = "1.0.107" | serde_json = "1.0.107" | ||||||
| sled = { version = "0.34.7" } | sled = { version = "0.34.7" } | ||||||
| sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio"] } | sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio", "chrono", "uuid"] } | ||||||
| uuid = { version = "1.4.1", features = ["serde", "v4"] } | uuid = { version = "1.4.1", features = ["serde", "v4"] } | ||||||
|  | 
 | ||||||
|  | [profile.dev.package.sqlx-macros] | ||||||
|  | opt-level = 3 | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| CREATE TABLE IF NOT EXISTS users ( | CREATE TABLE IF NOT EXISTS users ( | ||||||
|     id SERIAL PRIMARY KEY, |     id INTEGER PRIMARY KEY, | ||||||
|     username TEXT NOT NULL, |     username TEXT NOT NULL, | ||||||
|     password TEXT NOT NULL, |     password TEXT NOT NULL, | ||||||
|     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |  | ||||||
|  | @ -1,7 +1,9 @@ | ||||||
| CREATE TABLE IF NOT EXISTS posts ( | CREATE TABLE IF NOT EXISTS posts ( | ||||||
|     id SERIAL PRIMARY KEY, |     id INTEGER PRIMARY KEY NOT NULL, | ||||||
|     user_id SERIAL NOT NULL, |     user_id INTEGER NOT NULL, | ||||||
|     content TEXT NOT NULL, |     content TEXT NOT NULL, | ||||||
|  |     upvotes INT NOT NULL DEFAULT 0, | ||||||
|  |     downvotes INT NOT NULL DEFAULT 0, | ||||||
|     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|     updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |     updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|     FOREIGN KEY (user_id) REFERENCES users (id) |     FOREIGN KEY (user_id) REFERENCES users (id) | ||||||
|  |  | ||||||
							
								
								
									
										36
									
								
								server/src/db.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								server/src/db.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | ||||||
|  | use crate::routes::{NewPost, Post}; | ||||||
|  | use log::warn; | ||||||
|  | use sqlx::{Row, SqlitePool}; | ||||||
|  | 
 | ||||||
|  | // Gets all posts from the database
 | ||||||
|  | pub async fn db_get_posts(pool: &SqlitePool) -> Vec<Post> { | ||||||
|  |     sqlx::query_as!(Post, "SELECT * FROM posts") | ||||||
|  |         .fetch_all(pool) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Inserts a new post to the database
 | ||||||
|  | pub async fn db_new_post(post: NewPost, pool: &SqlitePool) -> Option<Post> { | ||||||
|  |     let q2 = sqlx::query!( | ||||||
|  |         "INSERT INTO posts (user_id, content) VALUES (1, ?)", | ||||||
|  |         post.content | ||||||
|  |     ) | ||||||
|  |     .execute(pool) | ||||||
|  |     .await; | ||||||
|  | 
 | ||||||
|  |     if q2.is_err() { | ||||||
|  |         let s = q2.err().unwrap(); | ||||||
|  |         warn!("Error inserting post into database: {}", s); | ||||||
|  |         return None; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let q = sqlx::query_as!( | ||||||
|  |         Post, | ||||||
|  |         "SELECT * FROM posts WHERE id = (SELECT MAX(id) FROM posts)" | ||||||
|  |     ) | ||||||
|  |     .fetch_one(pool) | ||||||
|  |     .await | ||||||
|  |     .ok()?; | ||||||
|  |     Some(q) | ||||||
|  | } | ||||||
|  | @ -1,15 +1,16 @@ | ||||||
|  | // #![allow(unused_imports, dead_code, unused_variables, unused_mut)]
 | ||||||
| use actix_files::Files; | use actix_files::Files; | ||||||
| use actix_web::middleware; | use actix_web::middleware; | ||||||
| use actix_web::web::Data; | use actix_web::web::Data; | ||||||
| use actix_web::{web::scope, App, HttpServer}; | use actix_web::{web::scope, App, HttpServer}; | ||||||
| use log::info; | use log::info; | ||||||
| 
 | 
 | ||||||
|  | mod db; | ||||||
| mod jwt; | mod jwt; | ||||||
| mod routes; | mod routes; | ||||||
| mod state; | mod state; | ||||||
| mod types; |  | ||||||
| 
 | 
 | ||||||
| use routes::{get_posts, login, new_post, register, test}; | use routes::{get_posts, login, new_post, register}; | ||||||
| use state::ServerState; | use state::ServerState; | ||||||
| 
 | 
 | ||||||
| #[actix_web::main] | #[actix_web::main] | ||||||
|  | @ -23,12 +24,11 @@ async fn main() -> std::io::Result<()> { | ||||||
|         App::new() |         App::new() | ||||||
|             .wrap(middleware::Compress::default()) |             .wrap(middleware::Compress::default()) | ||||||
|             .wrap(middleware::Logger::default()) |             .wrap(middleware::Logger::default()) | ||||||
|  |             .wrap(middleware::NormalizePath::trim()) | ||||||
|             .service( |             .service( | ||||||
|                 scope("/api") |                 scope("/api") | ||||||
|                     .service(get_posts) |                     .service(get_posts) | ||||||
|                     .service(new_post) |                     .service(new_post) | ||||||
|                     .service(routes::vote) |  | ||||||
|                     .service(test) |  | ||||||
|                     .service(login) |                     .service(login) | ||||||
|                     .service(register) |                     .service(register) | ||||||
|                     .app_data(Data::new(data.clone())), |                     .app_data(Data::new(data.clone())), | ||||||
|  |  | ||||||
|  | @ -1,189 +0,0 @@ | ||||||
| use crate::jwt::token_factory; |  | ||||||
| use crate::types::{NewPost, Post}; |  | ||||||
| use crate::ServerState; |  | ||||||
| 
 |  | ||||||
| use actix_web::web::{Data, Path}; |  | ||||||
| use actix_web::{get, post, web::Json, HttpResponse, Responder, Result}; |  | ||||||
| use argon2::password_hash::rand_core::OsRng; |  | ||||||
| use argon2::password_hash::SaltString; |  | ||||||
| use argon2::password_hash::*; |  | ||||||
| use argon2::Argon2; |  | ||||||
| use argon2::PasswordHasher; |  | ||||||
| use argon2::PasswordVerifier; |  | ||||||
| use log::*; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use uuid::Uuid; |  | ||||||
| 
 |  | ||||||
| #[get("/")] |  | ||||||
| pub async fn get_posts(data: Data<ServerState>) -> impl Responder { |  | ||||||
|     match data.posts.lock() { |  | ||||||
|         Ok(posts) => { |  | ||||||
|             let posts: Vec<Post> = posts.values().cloned().collect(); |  | ||||||
|             HttpResponse::Ok().json(posts) |  | ||||||
|         } |  | ||||||
|         Err(e) => { |  | ||||||
|             warn!("Error: {:?}", e); |  | ||||||
|             HttpResponse::InternalServerError().body("Error") |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[post("/")] |  | ||||||
| pub async fn new_post(new_post: Json<NewPost>, data: Data<ServerState>) -> impl Responder { |  | ||||||
|     let post = Post::from(new_post.into_inner()); |  | ||||||
|     info!("Created post {:?}", post.uuid); |  | ||||||
| 
 |  | ||||||
|     // let q = "INSERT INTO posts (uuid, content, upvotes, downvotes) VALUES (?, ?, ?, ?)";
 |  | ||||||
|     // let query = sqlx::query(q)
 |  | ||||||
|     //     .bind(post.uuid)
 |  | ||||||
|     //     .bind(post.content)
 |  | ||||||
|     //     .bind(post.votes.up)
 |  | ||||||
|     //     .bind(post.votes.down);
 |  | ||||||
| 
 |  | ||||||
|     match data.posts.lock() { |  | ||||||
|         Ok(mut posts) => { |  | ||||||
|             posts.insert(post.uuid, post); |  | ||||||
|         } |  | ||||||
|         Err(e) => { |  | ||||||
|             warn!("Error: {:?}", e); |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     HttpResponse::Ok().json("Post added!") |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // This is a test route, returns "Hello, world!"
 |  | ||||||
| #[get("/test")] |  | ||||||
| pub async fn test(data: Data<ServerState>) -> impl Responder { |  | ||||||
|     match data.posts.lock() { |  | ||||||
|         Ok(posts) => { |  | ||||||
|             let posts: Vec<Post> = posts.values().cloned().collect(); |  | ||||||
|             HttpResponse::Ok().body(format!("Hello, world! {:?}", posts)) |  | ||||||
|         } |  | ||||||
|         Err(e) => { |  | ||||||
|             warn!("Error: {:?}", e); |  | ||||||
|             HttpResponse::InternalServerError().body("Error") |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Deserialize, Serialize)] |  | ||||||
| #[serde(rename_all = "snake_case")] |  | ||||||
| pub enum VoteDirection { |  | ||||||
|     Up, |  | ||||||
|     Down, |  | ||||||
|     Unupvote, |  | ||||||
|     Undownvote, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[post("vote/{uuid}/{direction}")] |  | ||||||
| pub async fn vote(params: Path<(Uuid, VoteDirection)>, data: Data<ServerState>) -> impl Responder { |  | ||||||
|     let (uuid, direction) = params.into_inner(); |  | ||||||
|     println!("Voting {:?} on post {:?}", direction, uuid); |  | ||||||
| 
 |  | ||||||
|     match data.posts.lock() { |  | ||||||
|         Ok(mut posts) => { |  | ||||||
|             let uuid = uuid; |  | ||||||
|             if let Some(post) = posts.get_mut(&uuid) { |  | ||||||
|                 match direction { |  | ||||||
|                     VoteDirection::Up => post.votes.up += 1, |  | ||||||
|                     VoteDirection::Unupvote => post.votes.up -= 1, |  | ||||||
|                     VoteDirection::Down => post.votes.down += 1, |  | ||||||
|                     VoteDirection::Undownvote => post.votes.down -= 1, |  | ||||||
|                 } |  | ||||||
|                 HttpResponse::Ok().body("Downvoted!") |  | ||||||
|             } else { |  | ||||||
|                 HttpResponse::NotFound().body("Post not found!") |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         Err(e) => { |  | ||||||
|             warn!("Error: {:?}", e); |  | ||||||
|             HttpResponse::InternalServerError().body("Error") |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] |  | ||||||
| pub struct RegisterData { |  | ||||||
|     username: String, |  | ||||||
|     password: String, |  | ||||||
|     captcha: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[post("/register")] |  | ||||||
| pub async fn register( |  | ||||||
|     data: Json<RegisterData>, |  | ||||||
|     state: Data<ServerState>, |  | ||||||
| ) -> Result<impl Responder> { |  | ||||||
|     let q = "SELECT password FROM users WHERE username = ?"; |  | ||||||
|     let query = sqlx::query(q).bind(&data.username); |  | ||||||
|     let result = query.fetch_one(&state.pool).await.ok(); |  | ||||||
|     if result.is_some() { |  | ||||||
|         info!("User \"{}\" already exists", data.username); |  | ||||||
|         return Ok(HttpResponse::BadRequest().json("Error")); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     let password = data.password.clone(); |  | ||||||
|     let salt = SaltString::generate(&mut OsRng); |  | ||||||
|     let phc_hash = Argon2::default().hash_password(password.as_bytes(), &salt); |  | ||||||
|     if let Ok(phc_hash) = phc_hash { |  | ||||||
|         info!("User: {} registered", data.username); |  | ||||||
|         let phc_hash = phc_hash.to_string(); |  | ||||||
|         let q = "INSERT INTO users (username, password) VALUES (?, ?)"; |  | ||||||
|         let query = sqlx::query(q).bind(&data.username).bind(&phc_hash); |  | ||||||
|         query.execute(&state.pool).await.unwrap(); |  | ||||||
|     } else { |  | ||||||
|         return Ok(HttpResponse::BadRequest().json("Error")); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     Ok(HttpResponse::Ok().json("User registered")) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] |  | ||||||
| pub struct LoginData { |  | ||||||
|     username: String, |  | ||||||
|     password: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| use sqlx::Row; |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] |  | ||||||
| struct LoginResponse { |  | ||||||
|     username: String, |  | ||||||
|     token: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[post("/login")] |  | ||||||
| pub async fn login(data: Json<LoginData>, state: Data<ServerState>) -> Result<impl Responder> { |  | ||||||
|     let q = "SELECT password FROM users WHERE username = ?"; |  | ||||||
|     let query = sqlx::query(q).bind(&data.username); |  | ||||||
|     let result = query.fetch_one(&state.pool).await.ok(); |  | ||||||
|     if let Some(row) = result { |  | ||||||
|         let phc_from_db = row.get::<String, _>("password"); |  | ||||||
|         let pwhash = PasswordHash::new(&phc_from_db).unwrap_or_else(|_| { |  | ||||||
|             warn!( |  | ||||||
|                 "Invalid hash for user {} fetched from database (not a valid PHC string)", |  | ||||||
|                 data.username |  | ||||||
|             ); |  | ||||||
|             panic!(); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         match Argon2::default().verify_password(data.password.as_bytes(), &pwhash) { |  | ||||||
|             Ok(_) => { |  | ||||||
|                 info!("User {} logged in", data.username); |  | ||||||
|                 let token = token_factory(&data.username).unwrap(); |  | ||||||
|                 println!("{:?}", token); |  | ||||||
|                 return Ok(HttpResponse::Ok().json(LoginResponse { |  | ||||||
|                     username: data.username.clone(), |  | ||||||
|                     token: token, |  | ||||||
|                 })); |  | ||||||
|             } |  | ||||||
|             Err(_) => { |  | ||||||
|                 info!("User \"{}\" failed to log in", data.username); |  | ||||||
|                 return Ok(HttpResponse::BadRequest().json("Error")); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     Ok(HttpResponse::Ok().json("What happens here???")) |  | ||||||
| } |  | ||||||
							
								
								
									
										5
									
								
								server/src/routes/mod.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								server/src/routes/mod.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | mod post; | ||||||
|  | mod users; | ||||||
|  | 
 | ||||||
|  | pub use post::*; | ||||||
|  | pub use users::*; | ||||||
							
								
								
									
										56
									
								
								server/src/routes/post.rs
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										56
									
								
								server/src/routes/post.rs
									
										
									
									
									
										Executable file
									
								
							|  | @ -0,0 +1,56 @@ | ||||||
|  | use crate::db::db_new_post; | ||||||
|  | use crate::ServerState; | ||||||
|  | 
 | ||||||
|  | use actix_web::web::Data; | ||||||
|  | use actix_web::{get, post, web::Json, HttpResponse, Responder, Result}; | ||||||
|  | use log::info; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::FromRow; | ||||||
|  | 
 | ||||||
|  | // The post as it is received from the client
 | ||||||
|  | // The token is used to identify the user
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct NewPost { | ||||||
|  |     pub content: String, | ||||||
|  |     pub token: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // The post as it is stored in the database, with all the related metadata
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Clone, FromRow)] | ||||||
|  | pub struct Post { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub user_id: i64, | ||||||
|  |     pub content: String, | ||||||
|  |     pub upvotes: i64, | ||||||
|  |     pub downvotes: i64, | ||||||
|  |     pub created_at: chrono::NaiveDateTime, | ||||||
|  |     pub updated_at: chrono::NaiveDateTime, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Clone, FromRow)] | ||||||
|  | pub struct User { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub username: String, | ||||||
|  |     pub password: String, | ||||||
|  |     pub created_at: chrono::NaiveDateTime, | ||||||
|  |     pub updated_at: chrono::NaiveDateTime, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[get("/posts")] | ||||||
|  | pub async fn get_posts(state: Data<ServerState>) -> Result<impl Responder> { | ||||||
|  |     let stream = sqlx::query_as!(Post, "SELECT * FROM posts"); | ||||||
|  | 
 | ||||||
|  |     let posts = stream.fetch_all(&state.pool).await.unwrap(); | ||||||
|  |     Ok(HttpResponse::Ok().json(posts)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[post("/posts")] | ||||||
|  | pub async fn new_post(new_post: Json<NewPost>, state: Data<ServerState>) -> Result<impl Responder> { | ||||||
|  |     return match db_new_post(new_post.into_inner(), &state.pool).await { | ||||||
|  |         Some(post) => { | ||||||
|  |             info!("Created post {:?}", post.id); | ||||||
|  |             Ok(HttpResponse::Ok().json(post)) | ||||||
|  |         } | ||||||
|  |         None => Ok(HttpResponse::InternalServerError().json("Error")), | ||||||
|  |     }; | ||||||
|  | } | ||||||
							
								
								
									
										116
									
								
								server/src/routes/users.rs
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										116
									
								
								server/src/routes/users.rs
									
										
									
									
									
										Executable file
									
								
							|  | @ -0,0 +1,116 @@ | ||||||
|  | use crate::jwt::token_factory; | ||||||
|  | use crate::ServerState; | ||||||
|  | 
 | ||||||
|  | use actix_web::web::Data; | ||||||
|  | use actix_web::{post, web::Json, HttpResponse, Responder, Result}; | ||||||
|  | use argon2::password_hash::rand_core::OsRng; | ||||||
|  | use argon2::password_hash::SaltString; | ||||||
|  | use argon2::password_hash::*; | ||||||
|  | use argon2::Argon2; | ||||||
|  | use argon2::PasswordHasher; | ||||||
|  | use argon2::PasswordVerifier; | ||||||
|  | use log::*; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct LoginData { | ||||||
|  |     username: String, | ||||||
|  |     password: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | struct LoginResponse { | ||||||
|  |     username: String, | ||||||
|  |     token: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct RegisterData { | ||||||
|  |     username: String, | ||||||
|  |     password: String, | ||||||
|  |     captcha: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[post("/register")] | ||||||
|  | pub async fn register( | ||||||
|  |     data: Json<RegisterData>, | ||||||
|  |     state: Data<ServerState>, | ||||||
|  | ) -> Result<impl Responder> { | ||||||
|  |     // First check if the user already exists
 | ||||||
|  |     let exists = sqlx::query!( | ||||||
|  |         "SELECT username FROM users WHERE username = ?", | ||||||
|  |         data.username | ||||||
|  |     ) | ||||||
|  |     .fetch_one(&state.pool) | ||||||
|  |     .await | ||||||
|  |     .ok() | ||||||
|  |     .map(|row| row.username); | ||||||
|  | 
 | ||||||
|  |     // Bail out if the user already exists
 | ||||||
|  |     if exists.is_some() { | ||||||
|  |         info!("User \"{}\" already exists", data.username); | ||||||
|  |         return Ok(HttpResponse::BadRequest().json("Error")); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Unwrapping here because if this fails, we have a serious problem
 | ||||||
|  |     let phc_hash = Argon2::default() | ||||||
|  |         .hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng)) | ||||||
|  |         .unwrap() | ||||||
|  |         .to_string(); | ||||||
|  | 
 | ||||||
|  |     // Insert our new user into the database
 | ||||||
|  |     sqlx::query!( | ||||||
|  |         "INSERT INTO users (username, password) VALUES (?, ?)", | ||||||
|  |         data.username, | ||||||
|  |         phc_hash | ||||||
|  |     ) | ||||||
|  |     .execute(&state.pool) | ||||||
|  |     .await | ||||||
|  |     .unwrap(); | ||||||
|  | 
 | ||||||
|  |     info!("User: {} registered", data.username); | ||||||
|  |     Ok(HttpResponse::Ok().json("User registered")) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[post("/login")] | ||||||
|  | pub async fn login(data: Json<LoginData>, state: Data<ServerState>) -> Result<impl Responder> { | ||||||
|  |     // let q = "SELECT password FROM users WHERE username = ?";
 | ||||||
|  |     // let query = sqlx::query(q).bind(&data.username);
 | ||||||
|  |     // let result = query.fetch_one(&state.pool).await.ok();
 | ||||||
|  | 
 | ||||||
|  |     let uname = data.username.clone(); | ||||||
|  |     let q = sqlx::query!("SELECT password FROM users WHERE username = ?", uname) | ||||||
|  |         .fetch_one(&state.pool) | ||||||
|  |         .await | ||||||
|  |         .ok(); | ||||||
|  | 
 | ||||||
|  |     if q.is_none() { | ||||||
|  |         info!("User \"{}\" failed to log in", data.username); | ||||||
|  |         return Ok(HttpResponse::BadRequest().json("Error")); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let phc_password = q.unwrap().password; | ||||||
|  |     let phc_password = PasswordHash::new(&phc_password).unwrap_or_else(|_| { | ||||||
|  |         warn!( | ||||||
|  |             "Invalid hash for user {} fetched from database (not a valid PHC string)", | ||||||
|  |             data.username | ||||||
|  |         ); | ||||||
|  |         panic!(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     match Argon2::default().verify_password(data.password.as_bytes(), &phc_password) { | ||||||
|  |         Ok(_) => { | ||||||
|  |             info!("User {} logged in", data.username); | ||||||
|  |             let token = token_factory(&data.username).unwrap(); | ||||||
|  |             println!("{:?}", token); | ||||||
|  |             return Ok(HttpResponse::Ok().json(LoginResponse { | ||||||
|  |                 username: data.username.clone(), | ||||||
|  |                 token: token, | ||||||
|  |             })); | ||||||
|  |         } | ||||||
|  |         Err(_) => { | ||||||
|  |             info!("User \"{}\" failed to log in", data.username); | ||||||
|  |             return Ok(HttpResponse::BadRequest().json("Error")); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,31 +1,58 @@ | ||||||
| use crate::types::Post; |  | ||||||
| use sqlx::Pool; | use sqlx::Pool; | ||||||
| use sqlx::Sqlite; | use sqlx::Sqlite; | ||||||
|  | use sqlx::SqlitePool; | ||||||
| use sqlx::{self, sqlite}; | use sqlx::{self, sqlite}; | ||||||
| use std::collections::BTreeMap; |  | ||||||
| use std::sync::Arc; |  | ||||||
| use std::sync::Mutex; |  | ||||||
| use uuid::Uuid; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct ServerState { | pub struct ServerState { | ||||||
|     pub posts: Arc<Mutex<BTreeMap<Uuid, Post>>>, |  | ||||||
|     pub pool: Pool<Sqlite>, |     pub pool: Pool<Sqlite>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl ServerState { | impl ServerState { | ||||||
|     pub async fn new() -> Self { |     pub async fn new() -> Self { | ||||||
|  |         // This is almost certainly bad practice for more reasons than I can count
 | ||||||
|  |         dotenvy::dotenv().ok(); | ||||||
|  |         let db_url = dotenvy::var("DATABASE_URL").unwrap_or(":memory:".to_string()); | ||||||
|  | 
 | ||||||
|         let pool = sqlite::SqlitePoolOptions::new() |         let pool = sqlite::SqlitePoolOptions::new() | ||||||
|             .max_connections(5) |             .max_connections(5) | ||||||
|             .connect(":memory:") |             .connect(&db_url) | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| 
 | 
 | ||||||
|         sqlx::migrate!("./migrations").run(&pool).await.unwrap(); |         sqlx::migrate!("./migrations").run(&pool).await.unwrap(); | ||||||
| 
 | 
 | ||||||
|         Self { |         #[cfg(debug_assertions)] | ||||||
|             posts: Arc::new(Mutex::new(BTreeMap::new())), |         debug_setup(&pool).await; | ||||||
|             pool: pool, | 
 | ||||||
|         } |         Self { pool } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // Inserts a bunch of dummy data into the database
 | ||||||
|  | // Mostly useful for debugging new posts, as we need to satisfy foreign key constraints.
 | ||||||
|  | #[cfg(debug_assertions)] | ||||||
|  | async fn debug_setup(pool: &SqlitePool) { | ||||||
|  |     use chrono::NaiveDateTime; | ||||||
|  |     use sqlx::query; | ||||||
|  | 
 | ||||||
|  |     let now = NaiveDateTime::from_timestamp(0, 0); | ||||||
|  | 
 | ||||||
|  |     query!( | ||||||
|  |         "INSERT INTO users (username, password, created_at, updated_at) VALUES ('test', 'test', ?, ?)", | ||||||
|  |         now, | ||||||
|  |         now | ||||||
|  |     ) | ||||||
|  |     .execute(pool) | ||||||
|  |     .await | ||||||
|  |     .unwrap(); | ||||||
|  | 
 | ||||||
|  |     query!( | ||||||
|  |         "INSERT INTO posts (user_id, content, created_at, updated_at) VALUES (1, 'Hello world!', ?, ?)", | ||||||
|  |         now, | ||||||
|  |         now | ||||||
|  |     ) | ||||||
|  |     .execute(pool) | ||||||
|  |     .await | ||||||
|  |     .unwrap(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,40 +0,0 @@ | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use uuid::Uuid; |  | ||||||
| 
 |  | ||||||
| // The post as it is received from the client
 |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] |  | ||||||
| pub struct NewPost { |  | ||||||
|     content: String, |  | ||||||
|     token: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // The post as it is stored in the database
 |  | ||||||
| #[derive(Debug, Serialize, Deserialize, Clone)] |  | ||||||
| pub struct Post { |  | ||||||
|     pub uuid: Uuid, |  | ||||||
|     pub content: String, |  | ||||||
|     pub votes: VoteCount, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl From<NewPost> for Post { |  | ||||||
|     fn from(post: NewPost) -> Self { |  | ||||||
|         Self { |  | ||||||
|             uuid: Uuid::new_v4(), |  | ||||||
|             content: post.content, |  | ||||||
|             votes: VoteCount::new(), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Part of the post struct
 |  | ||||||
| #[derive(Debug, Serialize, Deserialize, Clone)] |  | ||||||
| pub struct VoteCount { |  | ||||||
|     pub up: u32, |  | ||||||
|     pub down: u32, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl VoteCount { |  | ||||||
|     fn new() -> Self { |  | ||||||
|         Self { up: 0, down: 0 } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Imbus
						Imbus