Add simple yew frontend
This commit is contained in:
parent
a0e8cfe5f9
commit
bbaf01dafe
26 changed files with 711 additions and 31 deletions
9
project/backend/.env
Normal file
9
project/backend/.env
Normal file
|
@ -0,0 +1,9 @@
|
|||
APP_NAME=thesis
|
||||
BIND=127.0.0.1
|
||||
PORT=3880
|
||||
DOMAIN=localhost
|
||||
DATABASE_URL=mysql://diesel:fuel@localhost/thesis
|
||||
JWT_SECRET=taiCoh4dEvoo0IefEvoo0IefaiNai7uv
|
||||
SECRET=la4kuuPhSai2johyIephaa4fahm5Siey
|
||||
ALLOWED_ORIGIN=localhost
|
||||
COOKIE_SECURE=false
|
1
project/backend/.gitignore
vendored
Normal file
1
project/backend/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
target/
|
1944
project/backend/Cargo.lock
generated
Normal file
1944
project/backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
project/backend/Cargo.toml
Normal file
24
project/backend/Cargo.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "thesis-backend"
|
||||
version = "0.1.0"
|
||||
authors = ["Marko Korhonen <marko.korhonen@reekynet.com>"]
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-web = "2.0.0"
|
||||
actix-rt = "1.0.0"
|
||||
serde = { version = "1.0.104", features = ["derive"] }
|
||||
diesel = { version = "1.4.3", features = ["mysql", "r2d2", "chrono"] }
|
||||
dotenv = "0.15.0"
|
||||
bcrypt = "0.6.2"
|
||||
env_logger = "0.7.1"
|
||||
r2d2 = "0.8.8"
|
||||
crypto = "0.0.2"
|
||||
jsonwebtoken = "7.1.0"
|
||||
chrono = { version = "0.4.11", features = ["serde"] }
|
||||
actix-cors = "0.2.0"
|
||||
actix-identity = "0.2.1"
|
||||
futures = "0.3.4"
|
||||
actix-files = "0.2.1"
|
5
project/backend/diesel.toml
Normal file
5
project/backend/diesel.toml
Normal file
|
@ -0,0 +1,5 @@
|
|||
# For documentation on how to configure this file,
|
||||
# see diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
0
project/backend/migrations/.gitkeep
Normal file
0
project/backend/migrations/.gitkeep
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE users;
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE users (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(100) UNIQUE NOT NULL,
|
||||
`password` varchar(128) NOT NULL,
|
||||
`admin` boolean NOT NULL,
|
||||
`created_at` timestamp NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
15
project/backend/src/db_connection.rs
Normal file
15
project/backend/src/db_connection.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
use diesel::r2d2::{ConnectionManager, Pool, PoolError, PooledConnection};
|
||||
use diesel::MysqlConnection;
|
||||
|
||||
pub type DbPool = r2d2::Pool<ConnectionManager<MysqlConnection>>;
|
||||
pub type MyPooledConnection = PooledConnection<ConnectionManager<MysqlConnection>>;
|
||||
|
||||
fn init_pool(database_url: &str) -> Result<DbPool, PoolError> {
|
||||
let manager = ConnectionManager::<MysqlConnection>::new(database_url);
|
||||
Pool::builder().build(manager)
|
||||
}
|
||||
|
||||
pub fn get_pool() -> DbPool {
|
||||
let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
init_pool(&connspec).expect("Failed to create DB pool")
|
||||
}
|
33
project/backend/src/errors.rs
Normal file
33
project/backend/src/errors.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
use bcrypt::BcryptError;
|
||||
use diesel::result;
|
||||
use std::fmt;
|
||||
|
||||
pub enum CustomError {
|
||||
HashError(BcryptError),
|
||||
DBError(result::Error),
|
||||
PasswordMatchError(String),
|
||||
PasswordWrong(String),
|
||||
}
|
||||
|
||||
impl From<BcryptError> for CustomError {
|
||||
fn from(error: BcryptError) -> Self {
|
||||
CustomError::HashError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<result::Error> for CustomError {
|
||||
fn from(error: result::Error) -> Self {
|
||||
CustomError::DBError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CustomError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
CustomError::HashError(error) => write!(f, "{}", error),
|
||||
CustomError::DBError(error) => write!(f, "{}", error),
|
||||
CustomError::PasswordMatchError(error) => write!(f, "{}", error),
|
||||
CustomError::PasswordWrong(error) => write!(f, "{}", error),
|
||||
}
|
||||
}
|
||||
}
|
96
project/backend/src/handlers/authentication.rs
Normal file
96
project/backend/src/handlers/authentication.rs
Normal file
|
@ -0,0 +1,96 @@
|
|||
use crate::{
|
||||
db_connection::DbPool,
|
||||
errors::CustomError,
|
||||
handlers::pool_handler,
|
||||
models::user::{AuthUser, DeleteUser, RegisterUser, User},
|
||||
utils::jwt::{decode_token, encode_token, UserWithToken},
|
||||
};
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{delete, dev::Payload, post, web, FromRequest, HttpRequest, HttpResponse};
|
||||
use futures::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub type LoggedUser = UserWithToken;
|
||||
|
||||
impl FromRequest for LoggedUser {
|
||||
type Error = HttpResponse;
|
||||
type Config = ();
|
||||
type Future = Pin<Box<dyn Future<Output = Result<UserWithToken, HttpResponse>>>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||
let fut = Identity::from_request(req, payload);
|
||||
|
||||
Box::pin(async move {
|
||||
if let Some(identity) = fut.await?.identity() {
|
||||
let user = decode_token(&identity)?;
|
||||
return Ok(user);
|
||||
};
|
||||
Err(HttpResponse::Unauthorized().finish())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/auth/register")]
|
||||
pub async fn register(
|
||||
new_user: web::Json<RegisterUser>,
|
||||
pool: web::Data<DbPool>,
|
||||
) -> Result<HttpResponse, HttpResponse> {
|
||||
let connection = pool_handler(pool)?;
|
||||
let register_user = new_user
|
||||
.into_inner()
|
||||
.validation()
|
||||
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))?;
|
||||
|
||||
User::create(register_user, &connection)
|
||||
.map(|_r| HttpResponse::Ok().json("User created successfully"))
|
||||
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))
|
||||
}
|
||||
|
||||
#[post("/auth/delete")]
|
||||
pub async fn delete(
|
||||
user: LoggedUser,
|
||||
user_to_delete: web::Json<DeleteUser>,
|
||||
pool: web::Data<DbPool>,
|
||||
) -> Result<HttpResponse, HttpResponse> {
|
||||
if user.admin || user.username == user_to_delete.username {
|
||||
let connection = pool_handler(pool)?;
|
||||
user_to_delete.delete(&connection).map_err(|e| match e {
|
||||
CustomError::DBError(diesel::result::Error::NotFound) => {
|
||||
HttpResponse::NotFound().json(e.to_string())
|
||||
}
|
||||
_ => HttpResponse::InternalServerError().json(e.to_string()),
|
||||
})?;
|
||||
Ok(HttpResponse::Ok().json("User deleted successfully"))
|
||||
} else {
|
||||
Err(HttpResponse::Unauthorized().json("Only admins can delete users"))
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/auth/login")]
|
||||
pub async fn login(
|
||||
id: Identity,
|
||||
auth_user: web::Json<AuthUser>,
|
||||
pool: web::Data<DbPool>,
|
||||
) -> Result<HttpResponse, HttpResponse> {
|
||||
let connection = pool_handler(pool)?;
|
||||
|
||||
let user = auth_user.login(&connection).map_err(|e| match e {
|
||||
CustomError::DBError(diesel::result::Error::NotFound) => {
|
||||
HttpResponse::NotFound().json(e.to_string())
|
||||
}
|
||||
_ => HttpResponse::InternalServerError().json(e.to_string()),
|
||||
})?;
|
||||
|
||||
let token = encode_token(user.id, &user.username, user.admin).map_err(|e| match e {
|
||||
_ => HttpResponse::InternalServerError().finish(),
|
||||
})?;
|
||||
|
||||
id.remember(String::from(token));
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
#[delete("/auth/logout")]
|
||||
pub async fn logout(id: Identity) -> Result<HttpResponse, HttpResponse> {
|
||||
id.forget();
|
||||
Ok(HttpResponse::Ok().into())
|
||||
}
|
9
project/backend/src/handlers/mod.rs
Normal file
9
project/backend/src/handlers/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use crate::db_connection::{DbPool, MyPooledConnection};
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
pub mod authentication;
|
||||
|
||||
pub fn pool_handler(pool: web::Data<DbPool>) -> Result<MyPooledConnection, HttpResponse> {
|
||||
pool.get()
|
||||
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))
|
||||
}
|
80
project/backend/src/main.rs
Normal file
80
project/backend/src/main.rs
Normal file
|
@ -0,0 +1,80 @@
|
|||
#[macro_use]
|
||||
extern crate diesel;
|
||||
extern crate dotenv;
|
||||
|
||||
pub mod db_connection;
|
||||
pub mod errors;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod schema;
|
||||
pub mod utils;
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_files::Files;
|
||||
use actix_identity::{CookieIdentityPolicy, IdentityService};
|
||||
use actix_web::{http::header, middleware::Logger, App, HttpServer};
|
||||
use chrono::Duration;
|
||||
use db_connection::get_pool;
|
||||
use dotenv::dotenv;
|
||||
use handlers::authentication;
|
||||
|
||||
pub fn get_env(var_name: &str) -> String {
|
||||
match std::env::var(&var_name) {
|
||||
Ok(var) => return var,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Failed to read required environment variable: {}",
|
||||
&var_name
|
||||
);
|
||||
eprintln!("Reason: {}", e.to_string());
|
||||
panic!("Can't continue without variable");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
std::env::set_var("RUST_LOG", "actix_web=debug,diesel=debug");
|
||||
env_logger::init();
|
||||
dotenv().ok();
|
||||
|
||||
let bind = get_env("BIND");
|
||||
let port = get_env("PORT");
|
||||
let address = format!("{}:{}", bind, port);
|
||||
|
||||
println!("Starting server at: http://{}", &address);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.wrap(
|
||||
Cors::new()
|
||||
.allowed_origin(get_env("ALLOWED_ORIGIN").as_str())
|
||||
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
|
||||
.allowed_headers(vec![
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
])
|
||||
.max_age(3600)
|
||||
.finish(),
|
||||
)
|
||||
.wrap(IdentityService::new(
|
||||
CookieIdentityPolicy::new(get_env("SECRET").as_bytes())
|
||||
.domain(get_env("DOMAIN"))
|
||||
.name(get_env("APP_NAME"))
|
||||
.path("/")
|
||||
.max_age(Duration::days(1).num_seconds())
|
||||
.secure(false),
|
||||
))
|
||||
.data(get_pool())
|
||||
.service(Files::new("/", "./static").index_file("index.html"))
|
||||
.service(authentication::register)
|
||||
.service(authentication::login)
|
||||
.service(authentication::logout)
|
||||
.service(authentication::delete)
|
||||
})
|
||||
.bind(address)?
|
||||
.run()
|
||||
.await
|
||||
}
|
15
project/backend/src/mariadb.rs
Normal file
15
project/backend/src/mariadb.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{self, ConnectionManager};
|
||||
|
||||
pub type DbPool = r2d2::Pool<ConnectionManager<DbConnection>>;
|
||||
pub type PooledConnection = r2d2::Pool<ConnectionManager<MysqlConnection>>;
|
||||
|
||||
fn init_pool(database_url: &str) -> Result<PgPool, PoolError> {
|
||||
let manager = ConnectionManager::<MysqlConnection>::new(database_url);
|
||||
r2d2::Pool::builder().build(manager)
|
||||
}
|
||||
|
||||
pub fn get_pool() -> DbPool {
|
||||
let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
init_pool(connspec).expect("Failed to create DB pool")
|
||||
}
|
1
project/backend/src/models/mod.rs
Normal file
1
project/backend/src/models/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod user;
|
119
project/backend/src/models/user.rs
Normal file
119
project/backend/src/models/user.rs
Normal file
|
@ -0,0 +1,119 @@
|
|||
use crate::{errors::CustomError, schema::users};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use chrono::{Local, NaiveDateTime};
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
||||
#[table_name = "users"]
|
||||
pub struct User {
|
||||
#[serde(skip)]
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
#[serde(skip)]
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
// another user struct for new users since id is missing on
|
||||
// creation (created by mariadb)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Insertable)]
|
||||
#[table_name = "users"]
|
||||
pub struct NewUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn hash_password(password: String) -> Result<String, CustomError> {
|
||||
Ok(hash(password, DEFAULT_COST)?)
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
register_user: RegisterUser,
|
||||
connection: &MysqlConnection,
|
||||
) -> Result<usize, CustomError> {
|
||||
use crate::schema::users::dsl::users;
|
||||
|
||||
let new_user = NewUser {
|
||||
username: register_user.username,
|
||||
admin: register_user.admin,
|
||||
password: Self::hash_password(register_user.password)?,
|
||||
created_at: Local::now().naive_local(),
|
||||
};
|
||||
|
||||
Ok(diesel::insert_into(users)
|
||||
.values(new_user)
|
||||
.execute(connection)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RegisterUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
pub password_confirmation: String,
|
||||
}
|
||||
|
||||
impl RegisterUser {
|
||||
pub fn validation(self) -> Result<RegisterUser, CustomError> {
|
||||
let passwords_are_equal = self.password == self.password_confirmation;
|
||||
let password_not_empty = self.password.len() > 0;
|
||||
|
||||
if passwords_are_equal && password_not_empty {
|
||||
Ok(self)
|
||||
} else if !passwords_are_equal {
|
||||
Err(CustomError::PasswordMatchError(
|
||||
"Password and confirmation do not match".to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(CustomError::PasswordWrong(
|
||||
"Wrong or empty password".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn login(&self, connection: &MysqlConnection) -> Result<User, CustomError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
let user = users
|
||||
.filter(username.eq(&self.username))
|
||||
.first::<User>(connection)?;
|
||||
|
||||
let verify_password = verify(&self.password, &user.password)
|
||||
.map_err(|_e| CustomError::PasswordWrong("Wrong password".to_string()))?;
|
||||
|
||||
if verify_password {
|
||||
Ok(user)
|
||||
} else {
|
||||
Err(CustomError::PasswordWrong("Wrong password".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUser {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl DeleteUser {
|
||||
pub fn delete(&self, connection: &MysqlConnection) -> Result<bool, CustomError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
match diesel::delete(users.filter(username.eq(&self.username))).execute(connection) {
|
||||
Ok(_r) => Ok(true),
|
||||
Err(e) => Err(CustomError::DBError(e)),
|
||||
}
|
||||
}
|
||||
}
|
9
project/backend/src/schema.rs
Normal file
9
project/backend/src/schema.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
table! {
|
||||
users (id) {
|
||||
id -> Integer,
|
||||
username -> Varchar,
|
||||
password -> Varchar,
|
||||
admin -> Bool,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
62
project/backend/src/utils/jwt.rs
Normal file
62
project/backend/src/utils/jwt.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
extern crate bcrypt;
|
||||
extern crate jsonwebtoken;
|
||||
|
||||
use crate::get_env;
|
||||
use actix_web::HttpResponse;
|
||||
use chrono::{Duration, Local};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: i32,
|
||||
name: String,
|
||||
admin: bool,
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserWithToken {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl From<Claims> for UserWithToken {
|
||||
fn from(claims: Claims) -> Self {
|
||||
UserWithToken {
|
||||
id: claims.sub,
|
||||
username: claims.name,
|
||||
admin: claims.admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
fn with_username(id: i32, username: &str, admin: bool) -> Self {
|
||||
Claims {
|
||||
sub: id,
|
||||
name: username.into(),
|
||||
admin,
|
||||
exp: (Local::now() + Duration::hours(24)).timestamp() as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_token(id: i32, username: &str, admin: bool) -> Result<String, HttpResponse> {
|
||||
let claims = Claims::with_username(id, username, admin);
|
||||
let secret = get_env("JWT_SECRET");
|
||||
let key = EncodingKey::from_secret(secret.as_bytes());
|
||||
|
||||
encode(&Header::default(), &claims, &key)
|
||||
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn decode_token(token: &str) -> Result<UserWithToken, HttpResponse> {
|
||||
let secret = get_env("JWT_SECRET");
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
|
||||
decode::<Claims>(token, &key, &Validation::default())
|
||||
.map(|data| data.claims.into())
|
||||
.map_err(|e| HttpResponse::Unauthorized().json(e.to_string()))
|
||||
}
|
1
project/backend/src/utils/mod.rs
Normal file
1
project/backend/src/utils/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod jwt;
|
1
project/backend/static
Symbolic link
1
project/backend/static
Symbolic link
|
@ -0,0 +1 @@
|
|||
../frontend/target/deploy
|
Loading…
Add table
Add a link
Reference in a new issue