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…
Reference in a new issue