diff --git a/server/Cargo.lock b/server/Cargo.lock index e7cf081..f5d2153 100755 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -286,6 +286,19 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "argon2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ba4cac0a46bc1d2912652a751c47f2a9f3a7fe89bcae2275d418f5270402f9" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + [[package]] name = "atoi" version = "2.0.0" @@ -343,6 +356,15 @@ dependencies = [ "serde", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1294,6 +1316,17 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.14" @@ -1571,6 +1604,7 @@ name = "server" version = "0.1.0" dependencies = [ "actix-web", + "argon2", "clap", "env_logger", "log", @@ -1746,6 +1780,8 @@ dependencies = [ "smallvec", "sqlformat", "thiserror", + "tokio", + "tokio-stream", "tracing", "url", ] @@ -1784,6 +1820,7 @@ dependencies = [ "sqlx-sqlite", "syn 1.0.109", "tempfile", + "tokio", "url", ] @@ -2037,6 +2074,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.9" diff --git a/server/Cargo.toml b/server/Cargo.toml index d094313..f5654ec 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -7,11 +7,12 @@ edition = "2021" [dependencies] actix-web = "4.4.0" +argon2 = { version = "0.5.2", features = ["zeroize"] } clap = { version = "4.4.5", features = ["derive"] } env_logger = "0.10.0" log = "0.4.20" serde = { version = "1.0.188", features = ["derive"] } serde_json = "1.0.107" sled = { version = "0.34.7" } -sqlx = "0.7.2" +sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio"] } uuid = { version = "1.4.1", features = ["serde", "v4"] } diff --git a/server/build.rs b/server/build.rs new file mode 100644 index 0000000..d506869 --- /dev/null +++ b/server/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/server/migrations/0001_users_table.sql b/server/migrations/0001_users_table.sql new file mode 100644 index 0000000..ae0b2b6 --- /dev/null +++ b/server/migrations/0001_users_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +create index users_username_index on users (username); \ No newline at end of file diff --git a/server/migrations/0002_posts_table.sql b/server/migrations/0002_posts_table.sql new file mode 100644 index 0000000..35c0660 --- /dev/null +++ b/server/migrations/0002_posts_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, + user_id SERIAL NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) +); +create index IF NOT EXISTS posts_user_id_index on posts (user_id); +create index IF NOT EXISTS posts_id_index on posts (id); \ No newline at end of file diff --git a/server/src/main.rs b/server/src/main.rs index a86db5f..b07876a 100755 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,21 +1,31 @@ -use actix_web::web::Data; +#![allow(dead_code, unused_imports)] +use actix_web::web::{Data, Query}; use actix_web::{web::scope, App, HttpServer}; +use log::info; // use uuid::Uuid; mod routes; +mod state; mod types; -use log::info; -use routes::{get_posts, new_post, test}; -use types::AppState; +use routes::{get_posts, new_post, register, test}; +use sqlx::ConnectOptions; +use state::AppState; + +use sqlx::{migrate::MigrateDatabase, query, sqlite}; + +struct User { + name: String, + pass: String, +} #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); - info!("Starting server..."); - let data = AppState::new(); + let data = AppState::new().await; + info!("Spinning up server on http://localhost:8080"); HttpServer::new(move || { App::new().service( scope("api") @@ -23,6 +33,7 @@ async fn main() -> std::io::Result<()> { .service(new_post) .service(routes::vote) .service(test) + .service(register) .app_data(Data::new(data.clone())), ) }) diff --git a/server/src/routes.rs b/server/src/routes.rs index 362a144..fe4c050 100755 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1,6 +1,9 @@ -use crate::types::{AppState, NewPost, Post}; +use crate::types::{NewPost, Post}; +use crate::AppState; + use actix_web::web::{Data, Path}; -use actix_web::{get, post, web::Json, HttpResponse, Responder}; +use actix_web::{get, post, web::Json, HttpResponse, Responder, Result}; +use argon2::password_hash::SaltString; use log::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -24,6 +27,13 @@ pub async fn new_post(new_post: Json, data: Data) -> impl Res 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); @@ -86,3 +96,44 @@ pub async fn vote(params: Path<(Uuid, VoteDirection)>, data: Data) -> } } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterData { + username: String, + password: String, + captcha: String, +} + +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::*; +use argon2::Algorithm; +use argon2::Argon2; +use argon2::PasswordHasher; +use argon2::PasswordVerifier; +use argon2::Version; + +#[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")) +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..7b5ca1f --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,31 @@ +use crate::types::Post; +use sqlx::Sqlite; +use sqlx::{self, sqlite}; +use sqlx::{AnyPool, Pool}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::Mutex; +use uuid::Uuid; + +#[derive(Clone)] +pub struct AppState { + pub posts: Arc>>, + pub pool: Pool, +} + +impl AppState { + pub async fn new() -> Self { + let pool = sqlite::SqlitePoolOptions::new() + .max_connections(5) + .connect(":memory:") + .await + .unwrap(); + + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + + Self { + posts: Arc::new(Mutex::new(BTreeMap::new())), + pool: pool, + } + } +} diff --git a/server/src/types.rs b/server/src/types.rs index 3b03bf6..a26e76c 100755 --- a/server/src/types.rs +++ b/server/src/types.rs @@ -4,16 +4,17 @@ use std::sync::Arc; use std::sync::Mutex; use uuid::Uuid; +// The post as it is received from the client #[derive(Debug, Serialize, Deserialize)] pub struct NewPost { - title: String, 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 title: String, pub content: String, pub votes: VoteCount, } @@ -22,26 +23,12 @@ impl From for Post { fn from(post: NewPost) -> Self { Self { uuid: Uuid::new_v4(), - title: post.title, content: post.content, votes: VoteCount::new(), } } } -#[derive(Clone)] -pub struct AppState { - pub posts: Arc>>, -} - -impl AppState { - pub fn new() -> Self { - Self { - posts: Arc::new(Mutex::new(BTreeMap::new())), - } - } -} - // Part of the post struct #[derive(Debug, Serialize, Deserialize, Clone)] pub struct VoteCount { diff --git a/src/LoginDialog.tsx b/src/LoginDialog.tsx index 08a58c3..b36b66b 100644 --- a/src/LoginDialog.tsx +++ b/src/LoginDialog.tsx @@ -10,6 +10,20 @@ import { useContext } from "react"; import { LoginContext } from "./Context"; // import { TestContext } from "./Context"; +interface RegisterData { + username: string, + password: string, + captcha: string, +} + +function sendRegister(data: RegisterData): void { + console.log(JSON.stringify(data)); + fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data) + }); +} function LoginDialog(): JSX.Element { // const [open, setOpen] = useState(openState); @@ -27,6 +41,10 @@ function LoginDialog(): JSX.Element { console.log(username, password); }; + const handleRegister = (): void => { + sendRegister({ username: username, password: password, captcha: "test" }); + } + return ( Login @@ -57,6 +75,9 @@ function LoginDialog(): JSX.Element { + + + ); } diff --git a/src/Post.tsx b/src/Post.tsx index f65d1a6..f9f64d1 100644 --- a/src/Post.tsx +++ b/src/Post.tsx @@ -9,10 +9,10 @@ export interface Post { votes: { up: number, down: number }, } -const URL = "http://localhost:8080/api/"; +// const URL = "http://localhost:8080/api/"; function sendVote(post: Post, direction: string): void { - fetch(URL + 'vote/' + post.uuid + '/' + direction, { method: 'POST' }); + fetch('/api/vote/' + post.uuid + '/' + direction, { method: 'POST' }); } enum VoteDirection { UP = 1, DOWN = -1, NONE = 0 } diff --git a/src/Primary.tsx b/src/Primary.tsx index 2d9d5ec..99922cc 100644 --- a/src/Primary.tsx +++ b/src/Primary.tsx @@ -8,7 +8,7 @@ function Primary(): JSX.Element { const [posts, setPosts] = useState([]); const refreshPosts = (): void => { - fetch("http://localhost:8080/api/").then((response): void => { + fetch("/api/").then((response): void => { response.json().then((data): void => { setPosts(data); }) @@ -41,19 +41,22 @@ function PostContainer({ posts }: { posts: Post[] }): JSX.Element { ) } +interface NewPost { + content: string, + token: string, +} function PostInput({ newPostCallback }: { newPostCallback: () => void }): JSX.Element { - const [cinput, setCinput] = useState(""); - const [title, setTitle] = useState(""); + const [currentInput, setCurrentInput] = useState(""); const handleSubmit = (): void => { - if (cinput && title) submitPostToServer(); + if (currentInput) submitPostToServer(); } const submitPostToServer = async (): Promise => { - const newPost = { title: title, content: cinput }; + const newPost: NewPost = { content: currentInput, token: "" } - const response = await fetch("http://localhost:8080/api/", { + const response = await fetch("/api/", { method: "POST", headers: { "Content-Type": "application/json" @@ -63,16 +66,15 @@ function PostInput({ newPostCallback }: { newPostCallback: () => void }): JSX.El const data = await response.json(); console.log(data); newPostCallback(); - setCinput(""); - setTitle(""); + setCurrentInput(""); } return ( - setTitle(a.target.value)} /> - setCinput(a.target.value)} /> + {/* setTitle(a.target.value)} /> */} + setCurrentInput(a.target.value)} /> diff --git a/vite.config.ts b/vite.config.ts index 090b928..a2d85aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,4 +5,16 @@ import { qrcode } from 'vite-plugin-qrcode' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), qrcode()], + server: { + port: 3000, + open: true, + proxy: { + '/api': { + target: 'http://localhost:8080/api', + changeOrigin: true, + secure: false, + rewrite: (path): string => path.replace(/^\/api/, '') + } + } + } })