Database integration WIP

This commit is contained in:
Imbus 2023-10-10 00:03:04 +02:00
parent f3e5cd62b1
commit 2bcda34f6a
13 changed files with 225 additions and 37 deletions

48
server/Cargo.lock generated
View file

@ -286,6 +286,19 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@ -343,6 +356,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -1294,6 +1316,17 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "paste" name = "paste"
version = "1.0.14" version = "1.0.14"
@ -1571,6 +1604,7 @@ name = "server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"argon2",
"clap", "clap",
"env_logger", "env_logger",
"log", "log",
@ -1746,6 +1780,8 @@ dependencies = [
"smallvec", "smallvec",
"sqlformat", "sqlformat",
"thiserror", "thiserror",
"tokio",
"tokio-stream",
"tracing", "tracing",
"url", "url",
] ]
@ -1784,6 +1820,7 @@ dependencies = [
"sqlx-sqlite", "sqlx-sqlite",
"syn 1.0.109", "syn 1.0.109",
"tempfile", "tempfile",
"tokio",
"url", "url",
] ]
@ -2037,6 +2074,17 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.9" version = "0.7.9"

View file

@ -7,11 +7,12 @@ edition = "2021"
[dependencies] [dependencies]
actix-web = "4.4.0" actix-web = "4.4.0"
argon2 = { version = "0.5.2", features = ["zeroize"] }
clap = { version = "4.4.5", features = ["derive"] } clap = { version = "4.4.5", features = ["derive"] }
env_logger = "0.10.0" env_logger = "0.10.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 = "0.7.2" sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio"] }
uuid = { version = "1.4.1", features = ["serde", "v4"] } uuid = { version = "1.4.1", features = ["serde", "v4"] }

5
server/build.rs Normal file
View file

@ -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");
}

View file

@ -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);

View file

@ -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);

View file

@ -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 actix_web::{web::scope, App, HttpServer};
use log::info;
// use uuid::Uuid; // use uuid::Uuid;
mod routes; mod routes;
mod state;
mod types; mod types;
use log::info; use routes::{get_posts, new_post, register, test};
use routes::{get_posts, new_post, test}; use sqlx::ConnectOptions;
use types::AppState; use state::AppState;
use sqlx::{migrate::MigrateDatabase, query, sqlite};
struct User {
name: String,
pass: String,
}
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 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 || { HttpServer::new(move || {
App::new().service( App::new().service(
scope("api") scope("api")
@ -23,6 +33,7 @@ async fn main() -> std::io::Result<()> {
.service(new_post) .service(new_post)
.service(routes::vote) .service(routes::vote)
.service(test) .service(test)
.service(register)
.app_data(Data::new(data.clone())), .app_data(Data::new(data.clone())),
) )
}) })

View file

@ -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::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 log::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -24,6 +27,13 @@ pub async fn new_post(new_post: Json<NewPost>, data: Data<AppState>) -> impl Res
let post = Post::from(new_post.into_inner()); let post = Post::from(new_post.into_inner());
info!("Created post {:?}", post.uuid); 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() { match data.posts.lock() {
Ok(mut posts) => { Ok(mut posts) => {
posts.insert(post.uuid, post); posts.insert(post.uuid, post);
@ -86,3 +96,44 @@ pub async fn vote(params: Path<(Uuid, VoteDirection)>, data: Data<AppState>) ->
} }
} }
} }
#[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<RegisterData>, state: Data<AppState>) -> 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"))
}

31
server/src/state.rs Normal file
View file

@ -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<Mutex<BTreeMap<Uuid, Post>>>,
pub pool: Pool<Sqlite>,
}
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,
}
}
}

View file

@ -4,16 +4,17 @@ use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use uuid::Uuid; use uuid::Uuid;
// The post as it is received from the client
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct NewPost { pub struct NewPost {
title: String,
content: String, content: String,
token: String,
} }
// The post as it is stored in the database
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Post { pub struct Post {
pub uuid: Uuid, pub uuid: Uuid,
pub title: String,
pub content: String, pub content: String,
pub votes: VoteCount, pub votes: VoteCount,
} }
@ -22,26 +23,12 @@ impl From<NewPost> for Post {
fn from(post: NewPost) -> Self { fn from(post: NewPost) -> Self {
Self { Self {
uuid: Uuid::new_v4(), uuid: Uuid::new_v4(),
title: post.title,
content: post.content, content: post.content,
votes: VoteCount::new(), votes: VoteCount::new(),
} }
} }
} }
#[derive(Clone)]
pub struct AppState {
pub posts: Arc<Mutex<BTreeMap<Uuid, Post>>>,
}
impl AppState {
pub fn new() -> Self {
Self {
posts: Arc::new(Mutex::new(BTreeMap::new())),
}
}
}
// Part of the post struct // Part of the post struct
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VoteCount { pub struct VoteCount {

View file

@ -10,6 +10,20 @@ import { useContext } from "react";
import { LoginContext } from "./Context"; import { LoginContext } from "./Context";
// import { TestContext } 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 { function LoginDialog(): JSX.Element {
// const [open, setOpen] = useState(openState); // const [open, setOpen] = useState(openState);
@ -27,6 +41,10 @@ function LoginDialog(): JSX.Element {
console.log(username, password); console.log(username, password);
}; };
const handleRegister = (): void => {
sendRegister({ username: username, password: password, captcha: "test" });
}
return ( return (
<Dialog open={loginCTX.loginModalOpen} onClose={handleClose} sx={{ p: 2, top: "-40%" }}> <Dialog open={loginCTX.loginModalOpen} onClose={handleClose} sx={{ p: 2, top: "-40%" }}>
<DialogTitle>Login</DialogTitle> <DialogTitle>Login</DialogTitle>
@ -57,6 +75,9 @@ function LoginDialog(): JSX.Element {
<DialogActions> <DialogActions>
<Button onClick={handleLogin}>Login</Button> <Button onClick={handleLogin}>Login</Button>
</DialogActions> </DialogActions>
<DialogActions>
<Button onClick={handleRegister}>Register</Button>
</DialogActions>
</Dialog> </Dialog>
); );
} }

View file

@ -9,10 +9,10 @@ export interface Post {
votes: { up: number, down: number }, votes: { up: number, down: number },
} }
const URL = "http://localhost:8080/api/"; // const URL = "http://localhost:8080/api/";
function sendVote(post: Post, direction: string): void { 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 } enum VoteDirection { UP = 1, DOWN = -1, NONE = 0 }

View file

@ -8,7 +8,7 @@ function Primary(): JSX.Element {
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const refreshPosts = (): void => { const refreshPosts = (): void => {
fetch("http://localhost:8080/api/").then((response): void => { fetch("/api/").then((response): void => {
response.json().then((data): void => { response.json().then((data): void => {
setPosts(data); 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 { function PostInput({ newPostCallback }: { newPostCallback: () => void }): JSX.Element {
const [cinput, setCinput] = useState(""); const [currentInput, setCurrentInput] = useState("");
const [title, setTitle] = useState("");
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (cinput && title) submitPostToServer(); if (currentInput) submitPostToServer();
} }
const submitPostToServer = async (): Promise<void> => { const submitPostToServer = async (): Promise<void> => {
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", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@ -63,16 +66,15 @@ function PostInput({ newPostCallback }: { newPostCallback: () => void }): JSX.El
const data = await response.json(); const data = await response.json();
console.log(data); console.log(data);
newPostCallback(); newPostCallback();
setCinput(""); setCurrentInput("");
setTitle("");
} }
return ( return (
<Box py={2} display={"flex"} flexDirection={"column"} width={1}> <Box py={2} display={"flex"} flexDirection={"column"} width={1}>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", alignContent: "center", alignItems: "center" }} elevation={4}> <Paper sx={{ p: 2, display: "flex", flexDirection: "column", alignContent: "center", alignItems: "center" }} elevation={4}>
<FormControl fullWidth> <FormControl fullWidth>
<TextField sx={{pb:1}} size="small" id="outlined-basic" label="Title" variant="outlined" value={title} fullWidth onChange={(a): void => setTitle(a.target.value)} /> {/* <TextField sx={{pb:1}} size="small" id="outlined-basic" label="Title" variant="outlined" value={title} fullWidth onChange={(a): void => setTitle(a.target.value)} /> */}
<TextField multiline id="outlined-basic" label="Share your thoughts" variant="outlined" minRows={4} value={cinput} fullWidth onChange={(a): void => setCinput(a.target.value)} /> <TextField multiline id="outlined-basic" label="Share your thoughts" variant="outlined" minRows={4} value={currentInput} fullWidth onChange={(a): void => setCurrentInput(a.target.value)} />
<Container disableGutters sx={{ mt:1, display: "flex", flexDirection: "row", justifyContent: "flex-end", alignItems: "center", width: "100%" }}> <Container disableGutters sx={{ mt:1, display: "flex", flexDirection: "row", justifyContent: "flex-end", alignItems: "center", width: "100%" }}>
<Button sx={{mr:1}} type="reset" startIcon={<CancelIcon/>} variant="outlined" onClick={handleSubmit}>Cancel</Button> <Button sx={{mr:1}} type="reset" startIcon={<CancelIcon/>} variant="outlined" onClick={handleSubmit}>Cancel</Button>
<Button type="submit" startIcon={<SendIcon />} variant="contained" onClick={handleSubmit}>Send</Button> <Button type="submit" startIcon={<SendIcon />} variant="contained" onClick={handleSubmit}>Send</Button>

View file

@ -5,4 +5,16 @@ import { qrcode } from 'vite-plugin-qrcode'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), qrcode()], 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/, '')
}
}
}
}) })