diff options
| author | Jokler <jokler@protonmail.com> | 2020-06-21 06:37:46 +0200 |
|---|---|---|
| committer | Jokler <jokler@protonmail.com> | 2020-06-21 06:37:46 +0200 |
| commit | e6468b012d5b33dd16992652da57f11dd5a6e82f (patch) | |
| tree | e89add440df79d4036b9b44d8c77ee6d69e67201 /src | |
| download | joklerpoints-e6468b012d5b33dd16992652da57f11dd5a6e82f.tar.gz joklerpoints-e6468b012d5b33dd16992652da57f11dd5a6e82f.zip | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/admin.rs | 114 | ||||
| -rw-r--r-- | src/admin/user_creation.rs | 87 | ||||
| -rw-r--r-- | src/error.rs | 44 | ||||
| -rw-r--r-- | src/error_handlers.rs | 33 | ||||
| -rw-r--r-- | src/main.rs | 115 | ||||
| -rw-r--r-- | src/model.rs | 54 | ||||
| -rw-r--r-- | src/schema.rs | 34 | ||||
| -rw-r--r-- | src/user.rs | 467 | ||||
| -rw-r--r-- | src/user/login.rs | 77 | ||||
| -rw-r--r-- | src/user/settings.rs | 36 | ||||
| -rw-r--r-- | src/user/transactions.rs | 175 | ||||
| -rw-r--r-- | src/util.rs | 37 |
12 files changed, 1273 insertions, 0 deletions
diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..2efe02f --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,114 @@ +use std::str::FromStr; + +use actix::Addr; +use actix_http::error::ResponseError; +use actix_identity::Identity; +use actix_web::{http::header, web, HttpResponse}; +use askama::Template; +use serde::Deserialize; + +use crate::error::ServiceError; +use crate::model::{DbExecutor, NewUser}; +use crate::util::UserRequest; +use crate::Secret; + +pub mod user_creation; +use user_creation::{PassHashExecutor, PassHashRequest, SaveUserRequest}; + +#[derive(Template)] +#[template(path = "admin.htm")] +struct AdminTemplate; + +pub async fn index(ident: Identity, db: web::Data<Addr<DbExecutor>>) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + let user = match db.send(UserRequest(name.clone())).await.unwrap() { + Ok(v) => v, + Err(_) => { + ident.forget(); + return HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish(); + } + }; + if user.power_level != 9001 { + return ServiceError::Unauthorized.error_response(); + } + + let page = AdminTemplate.render().unwrap(); + + HttpResponse::Ok().content_type("text/html").body(page) + } else { + HttpResponse::Found() + .header(header::LOCATION, "/user/login") + .finish() + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct UserData { + pub user: String, + pub balance: String, + pub password: String, +} + +pub async fn create_user( + ident: Identity, + params: web::Form<UserData>, + db: web::Data<Addr<DbExecutor>>, + hasher: web::Data<Addr<PassHashExecutor>>, + sec: web::Data<Secret>, +) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + let user = match db.send(UserRequest(name.clone())).await.unwrap() { + Ok(v) => v, + Err(_) => { + ident.forget(); + return HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish(); + } + }; + if user.power_level != 9001 { + return ServiceError::Unauthorized.error_response(); + } + let new_login = params.into_inner(); + + let balance = match u64::from_str(&new_login.balance) { + Ok(v) => v, + Err(e) => { + return HttpResponse::Ok() + .content_type("text/html") + .body(e.to_string()) + } + }; + + let hash = hasher + .send(PassHashRequest::new(new_login.password, sec.0.clone())) + .await + .unwrap() + .unwrap(); + + let new_user = NewUser { + name: new_login.user, + // TODO Let the admin pick + power_level: 0, + balance: balance, + }; + + db.send(SaveUserRequest::new(new_user, hash)) + .await + .unwrap() + .unwrap(); + + HttpResponse::Found() + .header(header::LOCATION, "/admin") + .finish() + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} diff --git a/src/admin/user_creation.rs b/src/admin/user_creation.rs new file mode 100644 index 0000000..563b660 --- /dev/null +++ b/src/admin/user_creation.rs @@ -0,0 +1,87 @@ +use actix::{Actor, Handler, Message, SyncContext}; +use argonautica::{input::SecretKey, Hasher}; +use diesel::MysqlConnection; +use diesel::{ + dsl::sql, + types::{Bigint, Unsigned}, + RunQueryDsl, +}; + +use crate::error::ServiceError; +use crate::model::{DbExecutor, NewPassword, NewUser}; +use crate::schema::passwords::dsl::passwords; +use crate::schema::users::dsl::users; + +static LAST_ID_SQL: &'static str = "SELECT LAST_INSERT_ID()"; + +pub struct PassHashExecutor(pub Hasher<'static>); + +impl Actor for PassHashExecutor { + type Context = SyncContext<Self>; +} + +pub struct PassHashRequest { + password: String, + secret: String, +} + +impl PassHashRequest { + pub fn new(password: String, secret: String) -> Self { + Self { password, secret } + } +} + +impl Message for PassHashRequest { + type Result = Result<String, ServiceError>; +} + +impl Handler<PassHashRequest> for PassHashExecutor { + type Result = Result<String, ServiceError>; + + fn handle(&mut self, req: PassHashRequest, _: &mut Self::Context) -> Self::Result { + let secret = SecretKey::from_base64_encoded(req.secret)?; + Ok(self + .0 + .with_password(req.password) + .with_secret_key(secret) + .hash()?) + } +} + +pub struct SaveUserRequest { + user: NewUser, + pass_hash: String, +} + +impl SaveUserRequest { + pub fn new(user: NewUser, pass_hash: String) -> Self { + Self { user, pass_hash } + } +} + +impl Message for SaveUserRequest { + type Result = Result<(), ServiceError>; +} + +impl Handler<SaveUserRequest> for DbExecutor { + type Result = Result<(), ServiceError>; + + fn handle(&mut self, msg: SaveUserRequest, _: &mut Self::Context) -> Self::Result { + let conn: &MysqlConnection = &self.0.get().unwrap(); + + diesel::insert_into(users).values(&msg.user).execute(conn)?; + + let id = sql::<Unsigned<Bigint>>(LAST_ID_SQL).get_result(conn)?; + + let new_pass = NewPassword { + id: id, + hash: &msg.pass_hash, + }; + + diesel::insert_into(passwords) + .values(&new_pass) + .execute(conn)?; + + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..062bb3d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,44 @@ +use actix_web::{error::ResponseError, HttpResponse}; +use argonautica::Error as HashError; +use derive_more::Display; +use diesel::result::Error as DieselError; +use log::error; +use std::convert::From; + +#[derive(Debug, Display)] +pub enum ServiceError { + #[display(fmt = "Internal Server Error")] + InternalServerError, + + #[display(fmt = "Unauthorized")] + Unauthorized, + + #[display(fmt = "Not Found")] + NotFound, +} + +impl ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + match *self { + ServiceError::InternalServerError => { + HttpResponse::InternalServerError().body("Internal Server Error") + } + ServiceError::Unauthorized => HttpResponse::Unauthorized().body("Unauthorized"), + ServiceError::NotFound => HttpResponse::NotFound().body("404 Not Found"), + } + } +} + +impl From<DieselError> for ServiceError { + fn from(e: DieselError) -> ServiceError { + error!("Database error: {}", e); + ServiceError::InternalServerError + } +} + +impl From<HashError> for ServiceError { + fn from(e: HashError) -> ServiceError { + error!("Hash error: {}", e); + ServiceError::InternalServerError + } +} diff --git a/src/error_handlers.rs b/src/error_handlers.rs new file mode 100644 index 0000000..c25b1c5 --- /dev/null +++ b/src/error_handlers.rs @@ -0,0 +1,33 @@ +use actix_files::NamedFile; +use actix_web::middleware::errhandlers::ErrorHandlerResponse; +use actix_web::{dev, Responder, Result}; + +pub fn bad_request<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> { + let new_resp = NamedFile::open("static/errors/400.htm")? + .set_status_code(res.status()) + .respond_to(res.request()) + .into_inner()?; + Ok(ErrorHandlerResponse::Response( + res.into_response(new_resp.into_body()), + )) +} + +pub fn not_found<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> { + let new_resp = NamedFile::open("static/errors/404.htm")? + .set_status_code(res.status()) + .respond_to(res.request()) + .into_inner()?; + Ok(ErrorHandlerResponse::Response( + res.into_response(new_resp.into_body()), + )) +} + +pub fn internal_server_error<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> { + let new_resp = NamedFile::open("static/errors/500.htm")? + .set_status_code(res.status()) + .respond_to(res.request()) + .into_inner()?; + Ok(ErrorHandlerResponse::Response( + res.into_response(new_resp.into_body()), + )) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0f39622 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,115 @@ +#[macro_use] +extern crate diesel; +#[macro_use] +extern crate diesel_migrations; + +use actix::prelude::*; +use actix_web::middleware::{errhandlers::ErrorHandlers, Logger}; + +use actix_identity::{CookieIdentityPolicy, IdentityService}; +use actix_web::{http, web, App, HttpServer}; +use argonautica::{Hasher, Verifier}; + +use chrono::Duration; +use diesel::mysql::MysqlConnection; +use dotenv::dotenv; +use r2d2_diesel::ConnectionManager; + +mod admin; +mod error; +mod error_handlers; +mod model; +mod schema; +mod user; +mod util; + +use crate::admin::user_creation::PassHashExecutor; +use crate::model::DbExecutor; +use crate::user::login::HashVerifyExecutor; + +embed_migrations!(); + +pub struct Secret(String); + +#[actix_rt::main] +async fn main() -> std::io::Result<()> { + dotenv().ok(); + + std::env::set_var( + "RUST_LOG", + "joklerpoint=debug,actix_web=trace,actix_server=debug", + ); + env_logger::init(); + + let bind_host: String = + std::env::var("BIND_HOST").unwrap_or_else(|_| "127.0.0.1:8000".to_string()); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let manager = ConnectionManager::<MysqlConnection>::new(database_url); + let pool = r2d2::Pool::builder() + .build(manager) + .expect("Can to build connection pool"); + embedded_migrations::run(&*pool.get().expect("Can connect to mysql")) + .expect("Can run migrations"); + + let db_addr: Addr<DbExecutor> = SyncArbiter::start(4, move || DbExecutor(pool.clone())); + let verifier_addr: Addr<HashVerifyExecutor> = + SyncArbiter::start(4, move || HashVerifyExecutor(Verifier::default())); + let hasher_addr: Addr<PassHashExecutor> = + SyncArbiter::start(4, move || PassHashExecutor(Hasher::default())); + + HttpServer::new(move || { + let secret: String = std::env::var("SECRET_KEY").expect("SECRET_KEY env var should be set"); + let domain: String = std::env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string()); + let secure_cookies = &domain != "localhost"; + + let error_handlers = ErrorHandlers::new() + .handler( + http::StatusCode::INTERNAL_SERVER_ERROR, + error_handlers::internal_server_error, + ) + .handler(http::StatusCode::BAD_REQUEST, error_handlers::bad_request) + .handler(http::StatusCode::NOT_FOUND, error_handlers::not_found); + + App::new() + .data(db_addr.clone()) + .data(verifier_addr.clone()) + .data(hasher_addr.clone()) + .data(Secret(secret.clone())) + .wrap(error_handlers) + .wrap(IdentityService::new( + CookieIdentityPolicy::new(secret.as_bytes()) + .name("jkp-session") + .domain(domain.as_str()) + .max_age_time(Duration::days(30)) + .secure(secure_cookies), // this has to reflect the usage of https + )) + .wrap(Logger::default()) + .route("/", web::get().to(user::index)) + .service( + web::resource("/login") + .route(web::get().to(user::login)) + .route(web::post().to(user::confirm_login)), + ) + .route("/transactions", web::get().to(user::transactions)) + .service( + web::resource("/transfer") + .route(web::get().to(user::transfer)) + .route(web::post().to(user::confirm_transfer)), + ) + .route("/settings", web::get().to(user::settings)) + .route("/reset-password", web::post().to(user::reset_password)) + .route("/logout", web::get().to(user::logout)) + .service( + web::resource("/admin") + .route(web::get().to(admin::index)) + .route(web::post().to(admin::create_user)), + ) + .service(actix_files::Files::new("/static", "static/")) + }) + .bind(&bind_host)? + .run() + .await?; + + Ok(()) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..1d399da --- /dev/null +++ b/src/model.rs @@ -0,0 +1,54 @@ +use actix::{Actor, SyncContext}; +use chrono::NaiveDateTime; +use diesel::mysql::MysqlConnection; +use r2d2::Pool; +use r2d2_diesel::ConnectionManager; + +use crate::schema::{passwords, transactions, users}; + +pub struct DbExecutor(pub Pool<ConnectionManager<MysqlConnection>>); + +impl Actor for DbExecutor { + type Context = SyncContext<Self>; +} + +#[derive(Debug, Clone, Insertable)] +#[table_name = "transactions"] +pub struct NewTransaction<'a> { + pub sender: u64, + pub receiver: u64, + pub amount: u64, + pub sender_balance: u64, + pub receiver_balance: u64, + pub purpose: &'a str, +} + +#[derive(Debug, Clone, Queryable)] +pub struct User { + pub id: u64, + pub power_level: i32, + pub name: String, + pub created: NaiveDateTime, + pub balance: u64, +} + +#[derive(Debug, Clone, Insertable)] +#[table_name = "users"] +pub struct NewUser { + pub power_level: i32, + pub name: String, + pub balance: u64, +} + +#[derive(Debug, Clone, Queryable)] +pub struct Password { + pub id: u64, + pub hash: String, +} + +#[derive(Debug, Clone, Insertable)] +#[table_name = "passwords"] +pub struct NewPassword<'a> { + pub id: u64, + pub hash: &'a str, +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..2855927 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,34 @@ +table! { + transactions (id) { + id -> Unsigned<Bigint>, + date -> Timestamp, + sender -> Unsigned<Bigint>, + receiver -> Unsigned<Bigint>, + amount -> Unsigned<Bigint>, + sender_balance -> Unsigned<Bigint>, + receiver_balance -> Unsigned<Bigint>, + purpose -> Varchar, + } +} + +table! { + passwords (id) { + id -> Unsigned<Bigint>, + hash -> Char, + } +} + +table! { + users (id) { + id -> Unsigned<Bigint>, + power_level -> Integer, + name -> Varchar, + created -> Timestamp, + balance -> Unsigned<Bigint>, + } +} + +joinable!(passwords -> users (id)); + +allow_tables_to_appear_in_same_query!(transactions, users,); +allow_tables_to_appear_in_same_query!(passwords, users,); diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..8cb2c5f --- /dev/null +++ b/src/user.rs @@ -0,0 +1,467 @@ +use serde::{Deserialize, Serialize}; + +use actix::Addr; +use actix_identity::Identity; +use actix_web::{http::header, web, HttpResponse, Responder}; +use askama::Template; + +use log::error; + +use crate::model::DbExecutor; + +use crate::util::UserRequest; +use crate::Secret; + +pub mod login; +use login::{HashVerifyExecutor, HashVerifyRequest, LoginRequest}; + +mod settings; +use settings::UpdatePasswordRequest; + +mod transactions; +use transactions::{Transaction, TransactionsRequest, TransferRequest}; + +use crate::util::filters; + +use crate::admin::user_creation::{PassHashExecutor, PassHashRequest}; + +pub async fn index(ident: Identity) -> impl Responder { + if ident.identity().is_some() { + HttpResponse::Found() + .header(header::LOCATION, "/transactions") + .finish() + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} + +#[derive(Template)] +#[template(path = "login.htm")] +struct LoginTemplate { + failed: bool, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct LoginError { + #[serde(default)] + pub error: bool, +} + +pub async fn login(ident: Identity, params: web::Query<LoginError>) -> impl Responder { + if ident.identity().is_some() { + HttpResponse::Found() + .header(header::LOCATION, "/transactions") + .finish() + } else { + let failed = params.into_inner().error; + HttpResponse::Ok() + .content_type("text/html") + .body(LoginTemplate { failed }.render().unwrap()) + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct LoginData { + pub user: String, + pub password: String, +} + +pub async fn confirm_login( + ident: Identity, + params: web::Form<LoginData>, + db: web::Data<Addr<DbExecutor>>, + verifier: web::Data<Addr<HashVerifyExecutor>>, + sec: web::Data<Secret>, +) -> impl Responder { + let login = params.into_inner(); + + let (user, hash) = match db.send(LoginRequest(login.user)).await.unwrap() { + Ok(v) => v, + Err(_) => { + return HttpResponse::Found() + .header(header::LOCATION, "/login?error=true") + .finish() + } + }; + + if !verifier + .send(HashVerifyRequest::new(hash, login.password, sec.0.clone())) + .await + .unwrap() + .unwrap_or(false) + { + return HttpResponse::Found() + .header(header::LOCATION, "/login?error=true") + .finish(); + } + + ident.remember(user.name); + HttpResponse::Found() + .header(header::LOCATION, "/transactions") + .finish() +} + +#[derive(Template)] +#[template(path = "transactions.htm")] +struct TransactionsTemplate<'a> { + name: &'a str, + amount: u64, + transactions: &'a [Transaction], +} + +pub async fn transactions(ident: Identity, db: web::Data<Addr<DbExecutor>>) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + let user = match db.send(UserRequest(name.clone())).await.unwrap() { + Ok(v) => v, + Err(_) => { + ident.forget(); + return HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish(); + } + }; + + let transactions = match db.send(TransactionsRequest::new(user.id)).await.unwrap() { + Ok(transactions) => transactions, + Err(e) => { + error!("Transactions error: {}", e); + Vec::with_capacity(0) + } + }; + + let page = TransactionsTemplate { + name: &name, + amount: user.balance, + transactions: &transactions, + } + .render() + .unwrap(); + + HttpResponse::Ok().content_type("text/html").body(page) + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} + +#[derive(Template)] +#[template(path = "transfer.htm")] +struct TransferTemplate { + balance: u64, + recipient: String, + amount: u64, + purpose: String, + error: TransferResult, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct TransferQuery { + #[serde(default)] + pub result: TransferResult, + #[serde(default)] + pub recipient: String, + #[serde(default)] + pub amount: u64, + #[serde(default)] + pub purpose: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum TransferResult { + ZeroPoints, + SelfSend, + TransferError, + Empty, +} + +impl Default for TransferResult { + fn default() -> Self { + TransferResult::Empty + } +} + +pub async fn transfer( + ident: Identity, + db: web::Data<Addr<DbExecutor>>, + params: web::Query<TransferQuery>, +) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + let user = match db.send(UserRequest(name.clone())).await.unwrap() { + Ok(v) => v, + Err(_) => { + ident.forget(); + return HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish(); + } + }; + + let query = params.into_inner(); + let page = TransferTemplate { + balance: user.balance, + recipient: query.recipient, + amount: query.amount, + purpose: query.purpose, + error: query.result, + } + .render() + .unwrap(); + + HttpResponse::Ok().content_type("text/html").body(page) + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct TransferData { + pub recipient: String, + pub amount: u64, + pub purpose: String, +} + +pub async fn confirm_transfer( + ident: Identity, + params: web::Form<TransferData>, + db: web::Data<Addr<DbExecutor>>, +) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + + let form = params.into_inner(); + + if form.amount == 0 { + let query = TransferQuery { + result: TransferResult::ZeroPoints, + recipient: form.recipient, + amount: form.amount, + purpose: form.purpose, + }; + return HttpResponse::Found() + .header( + header::LOCATION, + format!( + "/transfer?{}", + serde_urlencoded::to_string(query).unwrap_or(String::with_capacity(0)) + ), + ) + .finish(); + } + + if name.to_lowercase() == form.recipient.to_lowercase() { + let query = TransferQuery { + result: TransferResult::SelfSend, + recipient: form.recipient, + amount: form.amount, + purpose: form.purpose, + }; + return HttpResponse::Found() + .header( + header::LOCATION, + format!( + "/transfer?{}", + serde_urlencoded::to_string(query).unwrap_or(String::with_capacity(0)) + ), + ) + .finish(); + } + let request = TransferRequest { + from: name, + to: form.recipient.clone(), + amount: form.amount, + purpose: form.purpose.clone(), + }; + if let Err(e) = db.send(request).await.unwrap() { + error!("Failed to transfer points: {}", e); + let query = TransferQuery { + result: TransferResult::TransferError, + recipient: form.recipient, + amount: form.amount, + purpose: form.purpose, + }; + + return HttpResponse::Found() + .header( + header::LOCATION, + format!( + "/transfer?{}", + serde_urlencoded::to_string(query).unwrap_or(String::with_capacity(0)) + ), + ) + .finish(); + } + + HttpResponse::Found() + .header(header::LOCATION, "/transactions") + .finish() + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} + +#[derive(Template)] +#[template(path = "settings.htm")] +struct SettingsTemplate { + result: PasswordResult, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct PasswordQuery { + #[serde(default)] + pub result: PasswordResult, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum PasswordResult { + Success, + InvalidOld, + Missmatch, + Empty, +} + +impl Default for PasswordResult { + fn default() -> Self { + PasswordResult::Empty + } +} + +pub async fn settings(ident: Identity, params: web::Query<PasswordQuery>) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + + let query = params.into_inner(); + HttpResponse::Ok().content_type("text/html").body( + SettingsTemplate { + result: query.result, + } + .render() + .unwrap(), + ) + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct PasswordReset { + pub old_password: String, + pub new_password: String, + pub confirm_password: String, +} + +pub async fn reset_password( + ident: Identity, + params: web::Form<PasswordReset>, + db: web::Data<Addr<DbExecutor>>, + verifier: web::Data<Addr<HashVerifyExecutor>>, + hasher: web::Data<Addr<PassHashExecutor>>, + sec: web::Data<Secret>, +) -> HttpResponse { + if let Some(name) = ident.identity() { + ident.remember(name.clone()); + let (user, hash) = match db.send(LoginRequest(name)).await.unwrap() { + Ok(v) => v, + Err(_) => { + ident.forget(); + return HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish(); + } + }; + + let reset = params.into_inner(); + if reset.new_password != reset.confirm_password { + let query = PasswordQuery { + result: PasswordResult::Missmatch, + }; + return HttpResponse::Found() + .header( + header::LOCATION, + format!( + "/settings?{}", + serde_urlencoded::to_string(query).unwrap_or(String::with_capacity(0)) + ), + ) + .finish(); + } + + if !verifier + .send(HashVerifyRequest::new( + hash, + reset.old_password, + sec.0.clone(), + )) + .await + .unwrap() + .unwrap_or(false) + { + let query = PasswordQuery { + result: PasswordResult::InvalidOld, + }; + return HttpResponse::Found() + .header( + header::LOCATION, + format!( + "/settings?{}", + serde_urlencoded::to_string(query).unwrap_or(String::with_capacity(0)) + ), + ) + .finish(); + } + + let hash = hasher + .send(PassHashRequest::new(reset.new_password, sec.0.clone())) + .await + .unwrap() + .unwrap(); + + db.send(UpdatePasswordRequest::new(user.id, hash)) + .await + .unwrap() + .unwrap(); + + let query = PasswordQuery { + result: PasswordResult::Success, + }; + + HttpResponse::Found() + .header( + header::LOCATION, + format!( + "/settings?{}", + serde_urlencoded::to_string(query).unwrap_or(String::with_capacity(0)) + ), + ) + .finish() + } else { + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() + } +} + +pub fn logout(ident: Identity) -> HttpResponse { + ident.forget(); + HttpResponse::Found() + .header(header::LOCATION, "/login") + .finish() +} diff --git a/src/user/login.rs b/src/user/login.rs new file mode 100644 index 0000000..67d4c24 --- /dev/null +++ b/src/user/login.rs @@ -0,0 +1,77 @@ +use actix::{Actor, Handler, Message, SyncContext}; +use argonautica::{input::SecretKey, Verifier}; +use diesel::MysqlConnection; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; + +use crate::error::ServiceError; +use crate::model::DbExecutor; +use crate::model::User; +use crate::schema::passwords::{columns as password_columns, dsl::passwords}; +use crate::schema::users::{columns as user_columns, dsl::users}; + +pub struct HashVerifyExecutor(pub Verifier<'static>); + +impl Actor for HashVerifyExecutor { + type Context = SyncContext<Self>; +} + +pub struct HashVerifyRequest { + hash: String, + password: String, + secret: String, +} + +impl HashVerifyRequest { + pub fn new(hash: String, password: String, secret: String) -> Self { + Self { + hash, + password, + secret, + } + } +} + +impl Message for HashVerifyRequest { + type Result = Result<bool, ServiceError>; +} + +impl Handler<HashVerifyRequest> for HashVerifyExecutor { + type Result = Result<bool, ServiceError>; + + fn handle(&mut self, req: HashVerifyRequest, _: &mut Self::Context) -> Self::Result { + let secret = SecretKey::from_base64_encoded(req.secret)?; + Ok(self + .0 + .with_hash(req.hash) + .with_password(req.password) + .with_secret_key(secret) + .verify()?) + } +} + +pub struct LoginRequest(pub String); + +impl Message for LoginRequest { + type Result = Result<(User, String), ServiceError>; +} + +impl Handler<LoginRequest> for DbExecutor { + type Result = Result<(User, String), ServiceError>; + + fn handle(&mut self, login: LoginRequest, _: &mut Self::Context) -> Self::Result { + let conn: &MysqlConnection = &self.0.get().unwrap(); + + let user = users + .filter(user_columns::name.eq(login.0)) + .first::<User>(conn) + .map_err(|_| ServiceError::NotFound)?; + + let hash = passwords + .filter(password_columns::id.eq(user.id)) + .select(password_columns::hash) + .first::<String>(conn) + .map_err(|_| ServiceError::NotFound)?; + + Ok((user, hash)) + } +} diff --git a/src/user/settings.rs b/src/user/settings.rs new file mode 100644 index 0000000..55e658c --- /dev/null +++ b/src/user/settings.rs @@ -0,0 +1,36 @@ +use actix::{Handler, Message}; +use diesel::MysqlConnection; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; + +use crate::error::ServiceError; +use crate::model::DbExecutor; +use crate::schema::passwords::{columns as password_columns, dsl::passwords}; + +pub struct UpdatePasswordRequest { + pub user: u64, + pub hash: String, +} + +impl UpdatePasswordRequest { + pub fn new(user: u64, hash: String) -> Self { + Self { user, hash } + } +} + +impl Message for UpdatePasswordRequest { + type Result = Result<(), ServiceError>; +} + +impl Handler<UpdatePasswordRequest> for DbExecutor { + type Result = Result<(), ServiceError>; + + fn handle(&mut self, req: UpdatePasswordRequest, _: &mut Self::Context) -> Self::Result { + let conn: &MysqlConnection = &self.0.get().unwrap(); + + diesel::update(passwords.filter(password_columns::id.eq(req.user))) + .set(password_columns::hash.eq(req.hash)) + .execute(conn)?; + + Ok(()) + } +} diff --git a/src/user/transactions.rs b/src/user/transactions.rs new file mode 100644 index 0000000..197b2ec --- /dev/null +++ b/src/user/transactions.rs @@ -0,0 +1,175 @@ +use actix::{Handler, Message}; +use chrono::NaiveDateTime; +use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, RunQueryDsl}; +use diesel::{Connection, MysqlConnection}; + +use crate::error::ServiceError; +use crate::model::{DbExecutor, NewTransaction, User}; +use crate::schema::transactions::{columns as transaction_columns, dsl::transactions}; +use crate::schema::users::{columns as user_columns, dsl::users}; + +#[derive(Debug, Clone, Queryable)] +struct TempTransaction { + pub id: u64, + pub date: NaiveDateTime, + pub sender: String, + pub receiver: u64, + pub amount: u64, + pub sender_balance: u64, + pub receiver_balance: u64, + pub purpose: String, +} + +#[derive(Debug, Clone)] +pub struct Transaction { + pub id: u64, + pub date: NaiveDateTime, + pub sender: String, + pub receiver: String, + pub amount: u64, + pub sender_balance: u64, + pub receiver_balance: u64, + pub purpose: String, +} + +pub struct TransactionsRequest { + id: u64, +} + +impl TransactionsRequest { + pub fn new(id: u64) -> Self { + Self { id } + } +} + +impl Message for TransactionsRequest { + type Result = Result<Vec<Transaction>, ServiceError>; +} + +impl Handler<TransactionsRequest> for DbExecutor { + type Result = Result<Vec<Transaction>, ServiceError>; + + fn handle(&mut self, req: TransactionsRequest, _: &mut Self::Context) -> Self::Result { + let conn: &MysqlConnection = &self.0.get().unwrap(); + + use transaction_columns as t_cols; + let temp = transactions + .filter( + transaction_columns::sender + .eq(req.id) + .or(transaction_columns::receiver.eq(req.id)), + ) + .inner_join(users.on(transaction_columns::sender.eq(user_columns::id))) + .select(( + t_cols::id, + t_cols::date, + user_columns::name, + t_cols::receiver, + t_cols::amount, + t_cols::sender_balance, + t_cols::receiver_balance, + t_cols::purpose, + )) + .order(transaction_columns::date.desc()) + .load::<TempTransaction>(conn) + .map_err(|_| ServiceError::NotFound)?; + + let recv_ids = temp.iter().map(|t| t.receiver).collect::<Vec<_>>(); + + let receivers = users + .select((user_columns::id, user_columns::name)) + .filter(user_columns::id.eq_any(recv_ids)) + .load::<(u64, String)>(conn)?; + + let mut output = Vec::with_capacity(temp.len()); + for t in temp { + let (_, name) = receivers + .iter() + .find(|(id, _)| id == &t.receiver) + .expect("oh no"); + + let t = Transaction { + id: t.id, + date: t.date, + sender: t.sender, + receiver: name.to_owned(), + amount: t.amount, + sender_balance: t.sender_balance, + receiver_balance: t.receiver_balance, + purpose: t.purpose, + }; + output.push(t); + } + + Ok(output) + } +} + +pub struct TransferRequest { + pub from: String, + pub to: String, + pub amount: u64, + pub purpose: String, +} + +impl Message for TransferRequest { + type Result = Result<(), ServiceError>; +} + +impl Handler<TransferRequest> for DbExecutor { + type Result = Result<(), ServiceError>; + + fn handle(&mut self, req: TransferRequest, _: &mut Self::Context) -> Self::Result { + let conn: &MysqlConnection = &self.0.get().unwrap(); + + Ok(conn.transaction::<_, diesel::result::Error, _>(|| { + // Lock the user record to avoid modification by other threads + users + .filter( + user_columns::name + .eq(&req.from) + .or(user_columns::name.eq(&req.to)), + ) + .for_update() + .execute(conn)?; + + let sender = users + .filter(user_columns::name.eq(&req.from)) + .first::<User>(conn)?; + + let new_sender_balance = sender + .balance + .checked_sub(req.amount) + .ok_or(diesel::result::Error::RollbackTransaction)?; + + diesel::update(users.find(sender.id)) + .set(user_columns::balance.eq(new_sender_balance)) + .execute(conn)?; + + let receiver = users + .filter(user_columns::name.eq(&req.to)) + .first::<User>(conn)?; + + let new_receiver_balance = receiver + .balance + .checked_add(req.amount) + .ok_or(diesel::result::Error::RollbackTransaction)?; + + diesel::update(users.find(receiver.id)) + .set(user_columns::balance.eq(new_receiver_balance)) + .execute(conn)?; + + let t = NewTransaction { + sender: sender.id, + receiver: receiver.id, + amount: req.amount, + sender_balance: new_sender_balance, + receiver_balance: new_receiver_balance, + purpose: &req.purpose, + }; + diesel::insert_into(transactions).values(&t).execute(conn)?; + + Ok(()) + })?) + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..944feb6 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,37 @@ +use actix::{Handler, Message}; +use diesel::{ExpressionMethods, MysqlConnection, QueryDsl, RunQueryDsl}; + +use crate::error::ServiceError; +use crate::model::{DbExecutor, User}; +use crate::schema::users::{columns as user_columns, dsl::users}; + +pub struct UserRequest(pub String); + +impl Message for UserRequest { + type Result = Result<User, ServiceError>; +} + +impl Handler<UserRequest> for DbExecutor { + type Result = Result<User, ServiceError>; + + fn handle(&mut self, req: UserRequest, _: &mut Self::Context) -> Self::Result { + let conn: &MysqlConnection = &self.0.get().unwrap(); + + users + .filter(user_columns::name.eq(req.0)) + .first::<User>(conn) + .map_err(|_| ServiceError::NotFound) + } +} + +pub mod filters { + use num_format::{CustomFormat, ToFormattedString}; + + pub fn fmt_points(amount: &u64) -> Result<String, askama::Error> { + let format = CustomFormat::builder() + .separator(" ") + .build() + .expect("Format is fine"); + Ok(format!("{} JKP", (*amount).to_formatted_string(&format))) + } +} |
