diff --git a/.containerignore b/.containerignore index 56c261b..2a485ca 100644 --- a/.containerignore +++ b/.containerignore @@ -3,7 +3,10 @@ # Server files /server/target +**/target # Client files /client/node_modules /client/dist +**/node_modules +**/dist \ No newline at end of file diff --git a/.gitignore b/.gitignore index a547bf3..135ea6f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? + +*.env +*.db* + +# Remove this if ever needed +*.sqlx diff --git a/client-solid/src/api.ts b/client-solid/src/api.ts index 70b9a6f..b1ed3a6 100644 --- a/client-solid/src/api.ts +++ b/client-solid/src/api.ts @@ -20,20 +20,20 @@ export interface Post extends NewPost { export async function getPosts(): Promise { // const res = await fetch(`${API_URL}/posts`); - const res = await fetch("/api/"); + const res = await fetch("/api/posts"); const data = await res.json(); return data; } export async function getPost(id: string): Promise { - const res = await fetch(`/api/${id}`); + const res = await fetch(`/api/posts/${id}`); const data = await res.json(); return data; } export async function createPost(post: NewPost): Promise { // await fetch(`${API_URL}`, { - await fetch("/api/", { + await fetch("/api/posts", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/server/Cargo.lock b/server/Cargo.lock index 033f1e9..93d4286 100755 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1782,6 +1782,7 @@ dependencies = [ "argon2", "chrono", "clap", + "dotenvy", "env_logger", "jsonwebtoken", "log", @@ -1945,6 +1946,7 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "dotenvy", @@ -1973,6 +1975,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", ] [[package]] @@ -2006,6 +2009,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-mysql", + "sqlx-postgres", "sqlx-sqlite", "syn 1.0.109", "tempfile", @@ -2024,6 +2028,7 @@ dependencies = [ "bitflags 2.4.0", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -2052,6 +2057,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -2065,6 +2071,7 @@ dependencies = [ "base64 0.21.4", "bitflags 2.4.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -2091,6 +2098,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -2101,6 +2109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d59dc83cf45d89c555a577694534fcd1b55c545a816c816ce51f20bbe56a4f3f" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -2114,6 +2123,7 @@ dependencies = [ "sqlx-core", "tracing", "url", + "uuid", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index 49e8521..8afda9d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -11,11 +11,15 @@ actix-web = "4.4.0" argon2 = { version = "0.5.2", features = ["zeroize"] } chrono = { version = "0.4.31", features = ["serde"] } clap = { version = "4.4.5", features = ["derive"] } +dotenvy = "0.15.7" env_logger = "0.10.0" jsonwebtoken = "8.3.0" log = "0.4.20" serde = { version = "1.0.188", features = ["derive"] } serde_json = "1.0.107" 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"] } + +[profile.dev.package.sqlx-macros] +opt-level = 3 diff --git a/server/migrations/0001_users_table.sql b/server/migrations/0001_users_table.sql index ae0b2b6..76976d2 100644 --- a/server/migrations/0001_users_table.sql +++ b/server/migrations/0001_users_table.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY, username TEXT NOT NULL, password TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/server/migrations/0002_posts_table.sql b/server/migrations/0002_posts_table.sql index 35c0660..c5d05e5 100644 --- a/server/migrations/0002_posts_table.sql +++ b/server/migrations/0002_posts_table.sql @@ -1,7 +1,9 @@ CREATE TABLE IF NOT EXISTS posts ( - id SERIAL PRIMARY KEY, - user_id SERIAL NOT NULL, + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER 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, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..c2f0f39 --- /dev/null +++ b/server/src/db.rs @@ -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 { + 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 { + 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) +} diff --git a/server/src/main.rs b/server/src/main.rs index ab9f334..18e07d2 100755 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,15 +1,16 @@ +// #![allow(unused_imports, dead_code, unused_variables, unused_mut)] use actix_files::Files; use actix_web::middleware; use actix_web::web::Data; use actix_web::{web::scope, App, HttpServer}; use log::info; +mod db; mod jwt; mod routes; mod state; -mod types; -use routes::{get_posts, login, new_post, register, test}; +use routes::{get_posts, login, new_post, register}; use state::ServerState; #[actix_web::main] @@ -23,12 +24,11 @@ async fn main() -> std::io::Result<()> { App::new() .wrap(middleware::Compress::default()) .wrap(middleware::Logger::default()) + .wrap(middleware::NormalizePath::trim()) .service( scope("/api") .service(get_posts) .service(new_post) - .service(routes::vote) - .service(test) .service(login) .service(register) .app_data(Data::new(data.clone())), diff --git a/server/src/routes.rs b/server/src/routes.rs deleted file mode 100755 index ef19c75..0000000 --- a/server/src/routes.rs +++ /dev/null @@ -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) -> impl Responder { - match data.posts.lock() { - Ok(posts) => { - let posts: Vec = 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, data: Data) -> 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) -> impl Responder { - match data.posts.lock() { - Ok(posts) => { - let posts: Vec = 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) -> 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, - state: Data, -) -> Result { - 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, state: Data) -> Result { - 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::("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???")) -} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..c02003b --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,5 @@ +mod post; +mod users; + +pub use post::*; +pub use users::*; diff --git a/server/src/routes/post.rs b/server/src/routes/post.rs new file mode 100755 index 0000000..cc7970c --- /dev/null +++ b/server/src/routes/post.rs @@ -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) -> Result { + 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, state: Data) -> Result { + 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")), + }; +} diff --git a/server/src/routes/users.rs b/server/src/routes/users.rs new file mode 100755 index 0000000..e5cdec8 --- /dev/null +++ b/server/src/routes/users.rs @@ -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, + state: Data, +) -> Result { + // 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, state: Data) -> Result { + // 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")); + } + } +} diff --git a/server/src/state.rs b/server/src/state.rs index 8699d93..783b08d 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,31 +1,58 @@ -use crate::types::Post; use sqlx::Pool; use sqlx::Sqlite; +use sqlx::SqlitePool; use sqlx::{self, sqlite}; -use std::collections::BTreeMap; -use std::sync::Arc; -use std::sync::Mutex; -use uuid::Uuid; #[derive(Clone)] pub struct ServerState { - pub posts: Arc>>, pub pool: Pool, } impl ServerState { 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() .max_connections(5) - .connect(":memory:") + .connect(&db_url) .await .unwrap(); sqlx::migrate!("./migrations").run(&pool).await.unwrap(); - Self { - posts: Arc::new(Mutex::new(BTreeMap::new())), - pool: pool, - } + #[cfg(debug_assertions)] + debug_setup(&pool).await; + + 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(); +} diff --git a/server/src/types.rs b/server/src/types.rs deleted file mode 100755 index 267be22..0000000 --- a/server/src/types.rs +++ /dev/null @@ -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 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 } - } -}