From 93fc994d1de122aa275b5fe7e3b05ff99cea044b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Thu, 14 Apr 2022 21:40:26 +0200 Subject: [PATCH] All some logic --- .cargo/config.toml | 5 + .env | 2 + Cargo.lock | 257 +++++++++++++++++++++++++++- db/migrate/202204131841_init.sql | 8 +- rustfmt.toml | 2 + web/Cargo.toml | 12 +- web/src/actors/database.rs | 59 +++++++ web/src/actors/database/accounts.rs | 83 +++++++++ web/src/actors/database/products.rs | 163 ++++++++++++++++++ web/src/actors/mod.rs | 2 +- web/src/logic/mod.rs | 13 +- web/src/main.rs | 250 +++++++++++++++++++++++++-- web/src/model.rs | 180 ++++++++++++++++++- web/src/routes/admin.rs | 23 --- web/src/routes/admin/mod.rs | 138 +++++++++++++++ web/src/routes/mod.rs | 72 +++++++- 16 files changed, 1217 insertions(+), 52 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .env create mode 100644 rustfmt.toml create mode 100644 web/src/actors/database/accounts.rs create mode 100644 web/src/actors/database/products.rs delete mode 100644 web/src/routes/admin.rs create mode 100644 web/src/routes/admin/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..3e26d32 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = [ + "-C", "link-arg=-fuse-ld=mold", +] diff --git a/.env b/.env new file mode 100644 index 0000000..93340f1 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +DATABASE_URL=postgres://postgres@localhost/bazzar +PASS_SALT=18CHwV7eGFAea16z+qMKZg diff --git a/Cargo.lock b/Cargo.lock index 20b74ca..228830b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,29 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "actix" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3720d0064a0ce5c0de7bd93bdb0a6caebab2a9b5668746145d7b3b0c5da02914" +dependencies = [ + "actix-rt 2.7.0", + "bitflags", + "bytes 1.1.0", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot 0.11.2", + "pin-project-lite 0.2.8", + "smallvec", + "tokio 1.17.0", + "tokio-util 0.6.9", +] + [[package]] name = "actix" version = "0.13.0" @@ -45,7 +68,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f396220495e64a3dd22e1f8cd16345fa494f8d9c3e79bfd92c74c7911b811c19" dependencies = [ - "actix", + "actix 0.13.0", "ahash", "log", ] @@ -307,6 +330,27 @@ dependencies = [ "twoway", ] +[[package]] +name = "actix-redis" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dde9fa8bde15d084d459eb59f766c08d00a6f550e7054187878fc9cbaa19115" +dependencies = [ + "actix 0.12.0", + "actix-rt 2.7.0", + "actix-service 2.0.2", + "actix-tls 3.0.3", + "actix-web 4.0.1", + "backoff", + "derive_more", + "futures-core", + "log", + "redis-async", + "time 0.3.9", + "tokio 1.17.0", + "tokio-util 0.6.9", +] + [[package]] name = "actix-router" version = "0.2.7" @@ -419,6 +463,28 @@ dependencies = [ "pin-project-lite 0.2.8", ] +[[package]] +name = "actix-session" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9138a66462f1e65da829f9c0de81b44a96dfe193a4f19bfea32ee2be312368" +dependencies = [ + "actix 0.12.0", + "actix-redis", + "actix-service 2.0.2", + "actix-utils 3.0.0", + "actix-web 4.0.1", + "anyhow", + "async-trait", + "derive_more", + "futures-core", + "rand 0.8.5", + "serde", + "serde_json", + "time 0.3.9", + "tracing", +] + [[package]] name = "actix-testing" version = "1.0.1" @@ -464,6 +530,22 @@ dependencies = [ "log", ] +[[package]] +name = "actix-tls" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fde0cf292f7cdc7f070803cb9a0d45c018441321a78b1042ffbbb81ec333297" +dependencies = [ + "actix-codec 0.5.0", + "actix-rt 2.7.0", + "actix-service 2.0.2", + "actix-utils 3.0.0", + "futures-core", + "log", + "pin-project-lite 0.2.8", + "tokio-util 0.7.1", +] + [[package]] name = "actix-utils" version = "1.0.6" @@ -527,7 +609,7 @@ dependencies = [ "actix-service 1.0.6", "actix-testing", "actix-threadpool", - "actix-tls", + "actix-tls 1.0.0", "actix-utils 1.0.6", "actix-web-codegen 0.2.2", "awc", @@ -721,6 +803,24 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "anyhow" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" + +[[package]] +name = "argon2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27e27b63e4a34caee411ade944981136fdfa535522dc9944d6700196cbd899f" +dependencies = [ + "base64ct", + "blake2", + "password-hash", + "rayon", +] + [[package]] name = "arrayvec" version = "0.7.2" @@ -793,6 +893,17 @@ dependencies = [ "serde_urlencoded 0.6.1", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.6", + "instant", + "rand 0.8.5", +] + [[package]] name = "backtrace" version = "0.3.64" @@ -836,30 +947,37 @@ checksum = "dea908e7347a8c64e378c17e30ef880ad73e3b4498346b055c2c00ea342f3179" name = "bazzar" version = "0.1.0" dependencies = [ - "actix", + "actix 0.13.0", "actix-auth", "actix-broker", "actix-cors", "actix-files", "actix-identity 0.4.0", "actix-multipart", + "actix-redis", "actix-rt 2.7.0", + "actix-session", "actix-web 4.0.1", "actix-web-opentelemetry", + "argon2", "chrono", "derive_more", "dotenv", + "gumdrop", "log", "parking_lot 0.12.0", "password-hash", "pretty_env_logger", + "rand_core 0.6.3", "serde", "serde_json", "sqlx", "tera", + "thiserror", "toml", "tracing", "uuid", + "validator", ] [[package]] @@ -885,6 +1003,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "blake2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388" +dependencies = [ + "digest 0.10.3", +] + [[package]] name = "block-buffer" version = "0.7.3" @@ -1158,6 +1285,31 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1145cf131a2c6ba0615079ab6a638f7e1973ac9c2634fcbeaaad6114246efe8c" +dependencies = [ + "autocfg", + "cfg-if 1.0.0", + "crossbeam-utils", + "lazy_static", + "memoffset", + "scopeguard", +] + [[package]] name = "crossbeam-queue" version = "0.3.5" @@ -1614,6 +1766,26 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gumdrop" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc700f989d2f6f0248546222d9b4258f5b02a171a431f8285a81c08142629e3" +dependencies = [ + "gumdrop_derive", +] + +[[package]] +name = "gumdrop_derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729f9bd3449d77e7831a18abfb7ba2f99ee813dfd15b8c2167c9a54ba20aa99d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "h2" version = "0.2.7" @@ -2672,6 +2844,45 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rayon" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221" +dependencies = [ + "autocfg", + "crossbeam-deque", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "num_cpus", +] + +[[package]] +name = "redis-async" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b00c604527d485d7a146d1e324ec1cf0a5ec522acb3d05bf7d51a9c28d7c0c" +dependencies = [ + "bytes 1.1.0", + "futures-channel", + "futures-sink", + "futures-util", + "log", + "tokio 1.17.0", + "tokio-util 0.6.9", +] + [[package]] name = "redox_syscall" version = "0.2.13" @@ -3478,6 +3689,20 @@ dependencies = [ "tokio 0.2.25", ] +[[package]] +name = "tokio-util" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e99e1983e5d376cd8eb4b66604d2e99e79f5bd988c3055891dcd8c9e2604cc0" +dependencies = [ + "bytes 1.1.0", + "futures-core", + "futures-sink", + "log", + "pin-project-lite 0.2.8", + "tokio 1.17.0", +] + [[package]] name = "tokio-util" version = "0.7.1" @@ -3749,6 +3974,32 @@ dependencies = [ "serde", ] +[[package]] +name = "validator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0f08911ab0fee2c5009580f04615fa868898ee57de10692a45da0c3bcc3e5e" +dependencies = [ + "idna", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded9d97e1d42327632f5f3bae6403c04886e2de3036261ef42deebd931a6a291" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/db/migrate/202204131841_init.sql b/db/migrate/202204131841_init.sql index 7aaa5c8..d0b71ef 100644 --- a/db/migrate/202204131841_init.sql +++ b/db/migrate/202204131841_init.sql @@ -61,9 +61,9 @@ CREATE TABLE order_items CREATE TABLE statistics ( - id serial not null primary key, - url varchar not null, - clicks int not null default 0, - date DATE not null default now(), + id serial not null primary key, + url varchar not null, + clicks int not null default 0, + date DATE not null default now(), CONSTRAINT positive_clicks check ( clicks >= 0 ) ); diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..8635cf9 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +use_small_heuristics = "Max" diff --git a/web/Cargo.toml b/web/Cargo.toml index dc71e0c..a5ca5c0 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -14,6 +14,10 @@ actix-multipart = { version = "0.4.0" } actix-broker = { version = "0.4.2" } actix-identity = { version = "0.4.0" } actix-web-opentelemetry = { version = "0.12.0" } +actix-session = { version = "0.6.2", features = ["actix-redis", "redis-actor-session"] } +actix-redis = { version = "0.11.0" } + +gumdrop = { version = "0.8.1" } tera = { version = "1.15.0" } @@ -28,6 +32,10 @@ toml = { version = "0.5.8" } sqlx = { version = "0.5.11", features = ["migrate", "runtime-actix-rustls", "all-types", "postgres"] } +thiserror = { version = "1.0.30" } + +validator = { version = "0.14.0" } + log = { version = "0.4.16" } pretty_env_logger = { version = "0.4.0" } @@ -36,4 +44,6 @@ dotenv = { version = "0.15.0" } derive_more = { version = "0.99.17" } parking_lot = { version = "0.12.0" } -password-hash = { version = "0.4.0" } \ No newline at end of file +password-hash = { version = "0.4.0", features = ["alloc"] } +argon2 = { version = "0.4.0", features = ["parallel", "password-hash"] } +rand_core = { version = "0.6", features = ["std"] } diff --git a/web/src/actors/database.rs b/web/src/actors/database.rs index e69de29..01e12e1 100644 --- a/web/src/actors/database.rs +++ b/web/src/actors/database.rs @@ -0,0 +1,59 @@ +use actix::{Actor, Context}; +use sqlx::PgPool; + +pub use accounts::*; +pub use products::*; + +mod accounts; +mod products; + +#[macro_export] +macro_rules! async_handler { + ($msg: ty, $async: ident, $res: ty) => { + impl Handler<$msg> for Database { + type Result = ResponseActFuture>; + + fn handle(&mut self, msg: $msg, _ctx: &mut Self::Context) -> Self::Result { + let pool = self.pool.clone(); + Box::pin(async { $async(msg, pool).await }.into_actor(self)) + } + } + }; +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to connect to database. {0:?}")] + Connect(sqlx::Error), + #[error("{0}")] + Account(accounts::Error), + #[error("{0}")] + Product(products::Error), +} + +pub type Result = std::result::Result; + +pub struct Database { + pool: PgPool, +} + +impl Clone for Database { + fn clone(&self) -> Self { + Self { pool: self.pool.clone() } + } +} + +impl Database { + pub(crate) async fn build(url: &str) -> Result { + let pool = sqlx::PgPool::connect(url).await.map_err(Error::Connect)?; + Ok(Database { pool }) + } + + pub fn pool(&self) -> &PgPool { + &self.pool + } +} + +impl Actor for Database { + type Context = Context; +} diff --git a/web/src/actors/database/accounts.rs b/web/src/actors/database/accounts.rs new file mode 100644 index 0000000..762d0fe --- /dev/null +++ b/web/src/actors/database/accounts.rs @@ -0,0 +1,83 @@ +use actix::{ActorFutureExt, Handler, ResponseActFuture, WrapFuture}; +use sqlx::PgPool; + +use crate::database::Database; +use crate::model::{AccountId, Email, FullAccount, Login, PassHash, Role}; + +use super::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Can't create account")] + CantCreate, +} + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct CreateAccount { + pub email: Email, + pub login: Login, + pub pass_hash: PassHash, + pub role: Role, +} + +impl Handler for Database { + type Result = ResponseActFuture>; + + fn handle(&mut self, msg: CreateAccount, _ctx: &mut Self::Context) -> Self::Result { + let db = self.pool.clone(); + Box::pin(async { create_account(msg, db).await }.into_actor(self).map(|res, _, _| res)) + } +} + +async fn create_account(msg: CreateAccount, db: PgPool) -> Result { + sqlx::query_as( + r#" +INSERT INTO accounts (login, email, role, pass_hash) +VALUES ($1, $2, $3, $4) +RETURNING id, email, login, pass_hash, role + "#, + ) + .bind(msg.login) + .bind(msg.email) + .bind(msg.role) + .bind(msg.pass_hash) + .fetch_one(&db) + .await + .map_err(|e| { + log::error!("{e:?}"); + super::Error::Account(Error::CantCreate) + }) +} + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct FindAccount { + pub account_id: AccountId, +} + +impl Handler for Database { + type Result = ResponseActFuture>; + + fn handle(&mut self, msg: FindAccount, _ctx: &mut Self::Context) -> Self::Result { + let pool = self.pool.clone(); + Box::pin(async { find_account(msg, pool).await }.into_actor(self)) + } +} + +async fn find_account(msg: FindAccount, db: PgPool) -> Result { + sqlx::query_as( + r#" +SELECT id, email, login, pass_hash, role +FROM accounts +WHERE id = $1 + "#, + ) + .bind(msg.account_id) + .fetch_one(&db) + .await + .map_err(|e| { + log::error!("{e:?}"); + super::Error::Account(Error::CantCreate) + }) +} diff --git a/web/src/actors/database/products.rs b/web/src/actors/database/products.rs new file mode 100644 index 0000000..22e5aea --- /dev/null +++ b/web/src/actors/database/products.rs @@ -0,0 +1,163 @@ +use actix::{Handler, Message, ResponseActFuture, WrapFuture}; +use sqlx::PgPool; + +use super::Result; +use crate::database::Database; +use crate::model::{ + PriceMajor, PriceMinor, Product, ProductCategory, ProductId, ProductLongDesc, ProductName, + ProductShortDesc, +}; +use crate::{database, model}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Unable to load all products")] + All, + #[error("Unable to create product")] + Create, + #[error("Unable to update product")] + Update, + #[error("Unable to delete product")] + Delete, +} + +#[derive(Message)] +#[rtype(result = "Result>")] +pub struct AllProducts; + +crate::async_handler!(AllProducts, all, Vec); + +async fn all(_msg: AllProducts, pool: PgPool) -> Result> { + sqlx::query_as( + r#" +SELECT id, + name, + short_description, + long_description, + category, + price_major, + price_minor +FROM products + "#, + ) + .fetch_all(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Product(Error::All) + }) +} + +#[derive(Message)] +#[rtype(result = "Result")] +pub struct CreateProduct { + pub name: ProductName, + pub short_description: ProductShortDesc, + pub long_description: ProductLongDesc, + pub category: Option, + pub price_major: PriceMajor, + pub price_minor: PriceMinor, +} + +crate::async_handler!(CreateProduct, create_product, Product); + +async fn create_product(msg: CreateProduct, pool: PgPool) -> Result { + sqlx::query_as( + r#" +INSERT INTO products (name, short_description, long_description, category, price_major, price_minor) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, + name, + short_description, + long_description, + category, + price_major, + price_minor + "#, + ) + .bind(msg.name) + .bind(msg.short_description) + .bind(msg.long_description) + .bind(msg.category) + .bind(msg.price_major) + .bind(msg.price_minor) + .fetch_one(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Product(Error::Create) + }) +} + +#[derive(Message)] +#[rtype(result = "Result")] +pub struct UpdateProduct { + pub id: ProductId, + pub name: ProductName, + pub short_description: ProductShortDesc, + pub long_description: ProductLongDesc, + pub category: Option, + pub price_major: PriceMajor, + pub price_minor: PriceMinor, +} + +crate::async_handler!(UpdateProduct, update_product, Product); + +async fn update_product(msg: UpdateProduct, pool: PgPool) -> Result { + sqlx::query_as( + r#" +UPDATE products +SET name = $1 AND + short_description = $2 AND + long_description = $3 AND + category = $4 AND + price_major = $5 AND + price_minor = $6 +WHERE id = $7 +RETURNING id, + name, + short_description, + long_description, + category, + price_major, + price_minor + "#, + ) + .bind(msg.name) + .bind(msg.short_description) + .bind(msg.long_description) + .bind(msg.category) + .bind(msg.price_major) + .bind(msg.price_minor) + .bind(msg.id) + .fetch_one(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Product(Error::Update) + }) +} + +#[derive(Message)] +#[rtype(result = "Result>")] +pub struct DeleteProduct { + pub product_id: ProductId, +} + +crate::async_handler!(DeleteProduct, delete_product, Option); + +async fn delete_product(msg: DeleteProduct, pool: PgPool) -> Result> { + sqlx::query_as( + r#" +DELETE FROM products +WHERE id = $1 + "#, + ) + .bind(msg.product_id) + .fetch_optional(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Product(Error::Delete) + }) +} diff --git a/web/src/actors/mod.rs b/web/src/actors/mod.rs index 3ea3f13..8fd0a6b 100644 --- a/web/src/actors/mod.rs +++ b/web/src/actors/mod.rs @@ -1 +1 @@ -pub mod database; \ No newline at end of file +pub mod database; diff --git a/web/src/logic/mod.rs b/web/src/logic/mod.rs index 965d999..67119cf 100644 --- a/web/src/logic/mod.rs +++ b/web/src/logic/mod.rs @@ -1 +1,12 @@ -mod order_state; \ No newline at end of file +use argon2::Argon2; +use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; + +mod order_state; + +pub fn hash_pass(pass: &str, salt: &SaltString) -> password_hash::Result { + Ok(Argon2::default().hash_password(pass.as_bytes(), &salt)?.to_string()) +} + +pub fn validate_password(pass: &str, pass_hash: &str) -> password_hash::Result<()> { + Argon2::default().verify_password(pass.as_bytes(), &PasswordHash::new(pass_hash)?) +} diff --git a/web/src/main.rs b/web/src/main.rs index 4cec9c0..5c73995 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -1,14 +1,242 @@ -mod actors; -mod logic; -mod model; -mod routes; +use std::sync::Arc; -use actix_web::{App, HttpServer}; +use actix::Actor; +use actix_session::{storage::RedisActorSessionStore, SessionMiddleware}; +use actix_web::cookie::Key; +use actix_web::middleware::Logger; +use actix_web::web::Data; +use actix_web::{web, App, HttpResponse, HttpServer}; +use gumdrop::Options; +use password_hash::SaltString; +use validator::{validate_email, validate_length}; -#[actix_web::main] // or #[tokio::main] -async fn main() -> std::io::Result<()> { - HttpServer::new(|| App::new().configure(routes::configure)) - .bind(("127.0.0.1", 8080))? - .run() - .await +use crate::actors::database; +use crate::logic::hash_pass; +use crate::model::{Email, Login, PassHash, Role}; + +pub mod actors; +pub mod logic; +pub mod model; +pub mod routes; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to boot. {0:?}")] + Boot(std::io::Error), + #[error("Unable to read password file. {0:?}")] + PassFile(std::io::Error), + #[error("Unable to read password from STDIN. {0:?}")] + ReadPass(std::io::Error), + #[error("{0}")] + Database(#[from] database::Error), +} + +pub type Result = std::result::Result; + +#[derive(Options, Debug)] +struct Opts { + help: bool, + #[options(command)] + cmd: Option, +} + +#[derive(Options, Debug)] +enum Command { + #[options(help = "Run server")] + Server(ServerOpts), + #[options(help = "Migrate database")] + Migrate(MigrateOpts), + #[options(help = "Generate new salt for passwords")] + GenerateHash(GenerateHashOpts), + #[options(help = "Create new account")] + CreateAccount(CreateAccountOpts), +} + +impl Default for Command { + fn default() -> Self { + Command::Server(ServerOpts::default()) + } +} + +#[derive(Options, Debug)] +struct GenerateHashOpts { + help: bool, +} + +#[derive(Options, Debug)] +struct ServerOpts { + help: bool, + bind: String, + port: u16, + db_url: Option, +} + +impl Default for ServerOpts { + fn default() -> Self { + Self { help: false, bind: "0.0.0.0".to_string(), port: 8080, db_url: None } + } +} + +impl ServerOpts { + fn db_url(&self) -> String { + self.db_url + .as_deref() + .map(String::from) + .or_else(|| std::env::var("DATABASE_URL").ok()) + .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) + } +} + +#[derive(Options, Debug)] +struct MigrateOpts { + help: bool, + db_url: Option, +} + +impl MigrateOpts { + fn db_url(&self) -> String { + self.db_url + .as_deref() + .map(String::from) + .or_else(|| std::env::var("DATABASE_URL").ok()) + .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) + } +} + +#[derive(Debug, Options)] +struct CreateAccountOpts { + help: bool, + #[options(command)] + cmd: Option, +} + +#[derive(Debug, Options)] +enum CreateAccountCmd { + Admin(CreateAccountDefinition), + User(CreateAccountDefinition), +} + +#[derive(Debug, Options)] +struct CreateAccountDefinition { + help: bool, + #[options(free)] + login: String, + #[options(free)] + email: String, + #[options(free)] + pass_file: Option, + #[options(help = "Database url, it will also look for DATABASE_URL env")] + db_url: Option, +} + +impl CreateAccountDefinition { + fn db_url(&self) -> String { + self.db_url + .as_deref() + .map(String::from) + .or_else(|| std::env::var("DATABASE_URL").ok()) + .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) + } +} + +pub struct Config { + pass_salt: SaltString, +} + +impl Config { + fn load() -> Self { + let pass_salt = + SaltString::new(&std::env::var("PASS_SALT").expect("PASS_SALT is required")) + .expect("Invalid password salt"); + Self { pass_salt } + } +} + +async fn server(opts: ServerOpts) -> Result<()> { + let secret_key = Key::generate(); + let redis_connection_string = "127.0.0.1:6379"; + + let config = Arc::new(Config::load()); + let db = database::Database::build(&opts.db_url()).await?.start(); + + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .wrap(actix_web::middleware::Compress::default()) + .wrap(SessionMiddleware::new( + RedisActorSessionStore::new(redis_connection_string), + secret_key.clone(), + )) + .app_data(Data::new(config.clone())) + .app_data(Data::new(db.clone())) + .configure(routes::configure) + .default_service(web::to(HttpResponse::Ok)) + }) + .bind((opts.bind, opts.port)) + .map_err(Error::Boot)? + .run() + .await + .map_err(Error::Boot) +} + +async fn migrate(opts: MigrateOpts) -> Result<()> { + let db = database::Database::build(&opts.db_url()).await?; + sqlx::migrate!("../db/migrate").run(db.pool()).await.expect("Failed to migrate"); + Ok(()) +} + +async fn generate_hash(_opts: GenerateHashOpts) -> Result<()> { + use argon2::password_hash::rand_core::OsRng; + let salt = SaltString::generate(&mut OsRng); + println!("{salt}"); + Ok(()) +} + +async fn create_account(opts: CreateAccountOpts) -> Result<()> { + let (role, opts) = match opts.cmd.expect("Account type is mandatory") { + CreateAccountCmd::Admin(opts) => (Role::Admin, opts), + CreateAccountCmd::User(opts) => (Role::User, opts), + }; + if !validate_email(&opts.email) { + panic!("Invalid email address"); + } + if !validate_length(&opts.login, Some(4), Some(100), None) { + panic!("Login must have at least 4 characters and no more than 100"); + } + let db = database::Database::build(&opts.db_url()).await?.start(); + let pass = match opts.pass_file { + Some(path) => std::fs::read_to_string(path).map_err(Error::PassFile)?, + None => { + let mut s = String::with_capacity(100); + std::io::stdin().read_line(&mut s).map_err(Error::ReadPass)?; + s + } + }; + let config = Config::load(); + let hash = hash_pass(&pass, &config.pass_salt).unwrap(); + + db.send(database::CreateAccount { + email: Email(opts.email), + login: Login(opts.login), + pass_hash: PassHash(hash), + role, + }) + .await + .unwrap() + .unwrap(); + Ok(()) +} + +#[actix_web::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + pretty_env_logger::init(); + + let opts: Opts = gumdrop::Options::parse_args_default_or_exit(); + match opts.cmd.unwrap_or_default() { + Command::Migrate(opts) => migrate(opts).await, + Command::Server(opts) => server(opts).await, + Command::GenerateHash(opts) => generate_hash(opts).await, + Command::CreateAccount(opts) => create_account(opts).await, + } } diff --git a/web/src/model.rs b/web/src/model.rs index f75c456..85bdca3 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -1,6 +1,12 @@ -use derive_more::Display; +use std::fmt::Formatter; -#[derive(sqlx::Type, Copy, Clone, Debug, Display)] +use derive_more::Display; +use serde::de::{Error, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +pub type RecordId = i32; + +#[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] pub enum OrderStatus { #[display(fmt = "Potwierdzone")] Confirmed, @@ -16,10 +22,178 @@ pub enum OrderStatus { Refunded, } -#[derive(sqlx::Type, Copy, Clone, Debug, Display)] +#[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] pub enum Role { #[display(fmt = "Adminitrator")] Admin, #[display(fmt = "Użytkownik")] User, } + +#[derive(sqlx::Type, Deserialize, Serialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct Login(pub String); + +#[derive(sqlx::Type, Serialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct Email(pub String); + +impl<'de> serde::Deserialize<'de> for Email { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct EmailVisitor {} + impl<'v> Visitor<'v> for EmailVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("this is not valid e-mail address") + } + + fn visit_str(self, s: &str) -> Result + where + E: Error, + { + if validator::validate_email(s) { + Ok(String::from(s)) + } else { + Err(E::custom("this is not email address")) + } + } + } + + Ok(Email(deserializer.deserialize_str(EmailVisitor {})?)) + } +} + +#[derive(sqlx::Type, Serialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct NonNegative(pub i32); + +impl<'de> serde::Deserialize<'de> for NonNegative { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct NonNegativeVisitor; + impl<'v> Visitor<'v> for NonNegativeVisitor { + type Value = i32; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("this is not valid e-mail address") + } + + fn visit_i32(self, v: i32) -> Result + where + E: Error, + { + if v >= 0 { + Ok(v) + } else { + Err(E::custom("Value must be equal or greater than 0")) + } + } + } + + Ok(NonNegative(deserializer.deserialize_i32(NonNegativeVisitor)?)) + } +} + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct Password(pub String); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct PasswordConfirmation(pub String); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct PassHash(pub String); + +impl PartialEq for Password { + fn eq(&self, other: &PasswordConfirmation) -> bool { + self.0 == other.0 + } +} + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct AccountId(pub RecordId); + +#[derive(sqlx::FromRow, Serialize, Deserialize)] +pub struct FullAccount { + pub id: AccountId, + pub email: Email, + pub login: Login, + pub pass_hash: PassHash, + pub role: Role, +} + +#[derive(sqlx::FromRow, Serialize, Deserialize)] +pub struct Account { + pub id: AccountId, + pub email: Email, + pub login: Login, + pub role: Role, +} + +impl From for Account { + fn from(FullAccount { id, email, login, pass_hash: _, role }: FullAccount) -> Self { + Self { id, email, login, role } + } +} + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct ProductId(pub RecordId); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct ProductName(pub String); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct ProductShortDesc(pub String); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct ProductLongDesc(pub String); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct ProductCategory(pub String); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct PriceMajor(NonNegative); + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct PriceMinor(NonNegative); + +#[derive(sqlx::FromRow, Serialize, Deserialize)] +pub struct Product { + pub id: ProductId, + pub name: ProductName, + pub short_description: ProductShortDesc, + pub long_description: ProductLongDesc, + pub category: Option, + pub price_major: PriceMajor, + pub price_minor: PriceMinor, +} diff --git a/web/src/routes/admin.rs b/web/src/routes/admin.rs deleted file mode 100644 index f1655a7..0000000 --- a/web/src/routes/admin.rs +++ /dev/null @@ -1,23 +0,0 @@ -// use actix_auth::login_required; -use actix_identity::Identity; -use actix_web::web::ServiceConfig; -use actix_web::{delete, get, post, HttpResponse}; - -#[delete("/admin/logout")] -async fn logout() -> HttpResponse { - HttpResponse::NotImplemented().body("") -} - -#[post("/admin/sign-in")] -async fn sign_in(_id: Identity) -> HttpResponse { - HttpResponse::NotImplemented().body("") -} - -#[get("/admin")] -async fn landing() -> HttpResponse { - HttpResponse::NotImplemented().body("") -} - -pub fn configure(config: &mut ServiceConfig) { - config.service(landing).service(sign_in).service(logout); -} diff --git a/web/src/routes/admin/mod.rs b/web/src/routes/admin/mod.rs new file mode 100644 index 0000000..4c1622f --- /dev/null +++ b/web/src/routes/admin/mod.rs @@ -0,0 +1,138 @@ +use actix::Addr; +use actix_session::Session; +use actix_web::web::{Data, Json, ServiceConfig}; +use actix_web::{delete, get, post, HttpResponse}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::database::Database; +use crate::logic::hash_pass; +use crate::model::{Account, Email, Login, PassHash, Password, PasswordConfirmation, Role}; +use crate::routes::{RequireLogin, Result}; +use crate::{database, routes, Config}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Can't register new account")] + Register, + #[error("Can't hash password")] + HashPass, + #[error("Internal server error")] + DatabaseConnection, + #[error("{0}")] + Database(#[from] database::Error), +} + +#[derive(Serialize)] +pub struct LogoutResponse {} + +#[delete("/admin/logout")] +async fn logout(session: Session) -> Result { + session.require_admin()?; + Ok(HttpResponse::NotImplemented().body("")) +} + +#[post("/admin/sign-in")] +async fn sign_in(_session: Session) -> Result { + Ok(HttpResponse::NotImplemented().body("")) +} + +#[derive(Deserialize)] +pub struct RegisterInput { + pub login: Login, + pub email: Email, + pub password: Password, + pub password_confirmation: PasswordConfirmation, + pub role: Role, +} + +#[derive(Serialize, Default)] +pub struct RegisterResponse { + pub success: bool, + pub errors: Vec, + pub account: Option, +} + +#[derive(Serialize)] +pub enum RegisterError { + PasswordDiffer, +} + +// login_required +#[post("/admin/register")] +async fn register( + session: Session, + Json(input): Json, + db: Data>, + config: Data>, +) -> Result { + let mut response = RegisterResponse::default(); + session.require_admin()?; + + if input.password != input.password_confirmation { + response.errors.push(RegisterError::PasswordDiffer); + } + + let hash = match hash_pass(&input.password.0, &config.pass_salt) { + Ok(s) => s, + Err(e) => { + log::error!("{e:?}"); + return Err(routes::Error::Admin(Error::HashPass)); + } + }; + + match db + .send(database::CreateAccount { + email: input.email, + login: input.login, + pass_hash: PassHash(hash), + role: input.role, + }) + .await + { + Ok(Ok(account)) => { + response.account = Some(account.into()); + } + Ok(Err(e)) => { + log::error!("{}", e); + return Err(super::Error::Admin(Error::Register)); + } + Err(e) => { + log::error!("{}", e); + return Err(super::Error::Admin(Error::Register)); + } + }; + + response.success = response.errors.is_empty(); + Ok(if response.success { + HttpResponse::NotImplemented().json(response) + } else { + HttpResponse::BadRequest().json(response) + }) +} + +#[get("/admin/api/v1/products")] +async fn api_v1_products(session: Session, db: Data>) -> Result { + session.require_admin()?; + + match db.send(database::AllProducts).await { + Ok(Ok(products)) => Ok(HttpResponse::Ok().json(products)), + Ok(Err(e)) => { + log::error!("{}", e); + Err(super::Error::Admin(Error::Database(e))) + } + Err(e) => { + log::error!("{}", e); + Err(super::Error::Admin(Error::DatabaseConnection)) + } + } +} + +#[get("/admin")] +async fn landing() -> Result { + Ok(HttpResponse::NotImplemented().body("")) +} + +pub fn configure(config: &mut ServiceConfig) { + config.service(landing).service(sign_in).service(logout).service(register); +} diff --git a/web/src/routes/mod.rs b/web/src/routes/mod.rs index 365ba05..e78046c 100644 --- a/web/src/routes/mod.rs +++ b/web/src/routes/mod.rs @@ -1,10 +1,72 @@ -mod admin; -mod public; +pub mod admin; +pub mod public; +use crate::model::RecordId; +use crate::routes; +use actix_session::Session; +use actix_web::body::BoxBody; use actix_web::web::ServiceConfig; +use actix_web::{HttpRequest, HttpResponse, Responder, ResponseError}; +use std::fmt::{Debug, Display, Formatter}; + +pub trait RequireLogin { + fn require_admin(&self) -> Result; +} + +impl RequireLogin for Session { + fn require_admin(&self) -> Result { + match self.get("admin_id") { + Ok(Some(id)) => Ok(id), + _ => Err(Error::Unauthorized), + } + } +} + +pub enum Error { + Unauthorized, + Admin(routes::admin::Error), +} + +impl Debug for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(self, f) + } +} + +#[derive(serde::Serialize)] +pub struct Failure { + pub errors: Vec, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Error::Unauthorized => String::from("Unauthorized"), + Error::Admin(e) => format!("{}", e), + }; + f.write_str(&serde_json::to_string(&Failure { errors: vec![msg] }).unwrap()) + } +} + +impl ResponseError for Error {} + +impl Responder for Error { + type Body = BoxBody; + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + match self { + Error::Unauthorized => HttpResponse::Unauthorized() + .content_type("application/json") + .body(format!("{}", self)), + Error::Admin(_) => HttpResponse::InternalServerError() + .content_type("application/json") + .body(format!("{}", self)), + } + } +} + +pub type Result = std::result::Result; pub fn configure(config: &mut ServiceConfig) { - config - .configure(public::configure) - .configure(admin::configure); + config.configure(public::configure).configure(admin::configure); }