summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorJokler <jokler@protonmail.com>2020-06-21 06:37:46 +0200
committerJokler <jokler@protonmail.com>2020-06-21 06:37:46 +0200
commite6468b012d5b33dd16992652da57f11dd5a6e82f (patch)
treee89add440df79d4036b9b44d8c77ee6d69e67201 /src
downloadjoklerpoints-master.tar.gz
joklerpoints-master.zip
Initial commitHEADmaster
Diffstat (limited to 'src')
-rw-r--r--src/admin.rs114
-rw-r--r--src/admin/user_creation.rs87
-rw-r--r--src/error.rs44
-rw-r--r--src/error_handlers.rs33
-rw-r--r--src/main.rs115
-rw-r--r--src/model.rs54
-rw-r--r--src/schema.rs34
-rw-r--r--src/user.rs467
-rw-r--r--src/user/login.rs77
-rw-r--r--src/user/settings.rs36
-rw-r--r--src/user/transactions.rs175
-rw-r--r--src/util.rs37
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)))
+ }
+}