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",
]
[[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"

View file

@ -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"] }

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 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())),
)
})

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::{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<NewPost>, data: Data<AppState>) -> 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<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 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<NewPost> 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<Mutex<BTreeMap<Uuid, Post>>>,
}
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 {

View file

@ -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 (
<Dialog open={loginCTX.loginModalOpen} onClose={handleClose} sx={{ p: 2, top: "-40%" }}>
<DialogTitle>Login</DialogTitle>
@ -57,6 +75,9 @@ function LoginDialog(): JSX.Element {
<DialogActions>
<Button onClick={handleLogin}>Login</Button>
</DialogActions>
<DialogActions>
<Button onClick={handleRegister}>Register</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -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 }

View file

@ -8,7 +8,7 @@ function Primary(): JSX.Element {
const [posts, setPosts] = useState<Post[]>([]);
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<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",
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 (
<Box py={2} display={"flex"} flexDirection={"column"} width={1}>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", alignContent: "center", alignItems: "center" }} elevation={4}>
<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 multiline id="outlined-basic" label="Share your thoughts" variant="outlined" minRows={4} value={cinput} fullWidth onChange={(a): void => setCinput(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={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%" }}>
<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>

View file

@ -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/, '')
}
}
}
})