From d2634598d571c7b5f2ec59788cc7344f30208eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Fri, 15 Apr 2022 17:04:23 +0200 Subject: [PATCH] Add endpoints, creating accounts and so on --- .env | 1 + Cargo.toml | 2 +- {web => api}/Cargo.lock | 0 {web => api}/Cargo.toml | 0 api/assets/index.html | 62 +++++++++ {web => api}/src/actors/database.rs | 4 + api/src/actors/database/accounts.rs | 120 +++++++++++++++++ {web => api}/src/actors/database/products.rs | 0 api/src/actors/database/stocks.rs | 128 +++++++++++++++++++ {web => api}/src/actors/mod.rs | 0 api/src/logic/mod.rs | 23 ++++ {web => api}/src/logic/order_state.rs | 0 {web => api}/src/main.rs | 52 ++++---- {web => api}/src/model.rs | 65 +++++++--- api/src/routes/admin/api_v1.rs | 8 ++ api/src/routes/admin/api_v1/products.rs | 111 ++++++++++++++++ api/src/routes/admin/api_v1/stocks.rs | 92 +++++++++++++ {web => api}/src/routes/admin/mod.rs | 108 +++++++++++----- {web => api}/src/routes/mod.rs | 5 +- api/src/routes/public.rs | 31 +++++ api/src/routes/public/api_v1.rs | 23 ++++ db/migrate/202204131841_init.sql | 43 ++++--- web/index.html | 12 ++ web/src/actors/database/accounts.rs | 83 ------------ web/src/logic/mod.rs | 12 -- web/src/routes/public.rs | 11 -- 26 files changed, 798 insertions(+), 198 deletions(-) rename {web => api}/Cargo.lock (100%) rename {web => api}/Cargo.toml (100%) create mode 100644 api/assets/index.html rename {web => api}/src/actors/database.rs (94%) create mode 100644 api/src/actors/database/accounts.rs rename {web => api}/src/actors/database/products.rs (100%) create mode 100644 api/src/actors/database/stocks.rs rename {web => api}/src/actors/mod.rs (100%) create mode 100644 api/src/logic/mod.rs rename {web => api}/src/logic/order_state.rs (100%) rename {web => api}/src/main.rs (86%) rename {web => api}/src/model.rs (80%) create mode 100644 api/src/routes/admin/api_v1.rs create mode 100644 api/src/routes/admin/api_v1/products.rs create mode 100644 api/src/routes/admin/api_v1/stocks.rs rename {web => api}/src/routes/admin/mod.rs (50%) rename {web => api}/src/routes/mod.rs (93%) create mode 100644 api/src/routes/public.rs create mode 100644 api/src/routes/public/api_v1.rs create mode 100644 web/index.html delete mode 100644 web/src/actors/database/accounts.rs delete mode 100644 web/src/logic/mod.rs delete mode 100644 web/src/routes/public.rs diff --git a/.env b/.env index 93340f1..377d163 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ DATABASE_URL=postgres://postgres@localhost/bazzar PASS_SALT=18CHwV7eGFAea16z+qMKZg +RUST_LOG=debug diff --git a/Cargo.toml b/Cargo.toml index d3a2a7d..5045187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["web"] \ No newline at end of file +members = ["api"] diff --git a/web/Cargo.lock b/api/Cargo.lock similarity index 100% rename from web/Cargo.lock rename to api/Cargo.lock diff --git a/web/Cargo.toml b/api/Cargo.toml similarity index 100% rename from web/Cargo.toml rename to api/Cargo.toml diff --git a/api/assets/index.html b/api/assets/index.html new file mode 100644 index 0000000..2fa58bd --- /dev/null +++ b/api/assets/index.html @@ -0,0 +1,62 @@ + + + + + Bazzar + + +
+
+
+ + + + +
+
+
+
+
+
+ + + diff --git a/web/src/actors/database.rs b/api/src/actors/database.rs similarity index 94% rename from web/src/actors/database.rs rename to api/src/actors/database.rs index 01e12e1..0ee117e 100644 --- a/web/src/actors/database.rs +++ b/api/src/actors/database.rs @@ -3,9 +3,11 @@ use sqlx::PgPool; pub use accounts::*; pub use products::*; +pub use stocks::*; mod accounts; mod products; +mod stocks; #[macro_export] macro_rules! async_handler { @@ -29,6 +31,8 @@ pub enum Error { Account(accounts::Error), #[error("{0}")] Product(products::Error), + #[error("{0}")] + Stock(stocks::Error), } pub type Result = std::result::Result; diff --git a/api/src/actors/database/accounts.rs b/api/src/actors/database/accounts.rs new file mode 100644 index 0000000..7939ee3 --- /dev/null +++ b/api/src/actors/database/accounts.rs @@ -0,0 +1,120 @@ +use crate::async_handler; +use actix::{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, + #[error("Can't find account does to lack of identity")] + NoIdentity, + #[error("Account does not exists")] + NotExists, +} + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct CreateAccount { + pub email: Email, + pub login: Login, + pub pass_hash: PassHash, + pub role: Role, +} + +async_handler!(CreateAccount, create_account, FullAccount); + +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, +} + +async_handler!(FindAccount, find_account, FullAccount); + +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::NotExists) + }) +} + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct AccountByIdentity { + pub login: Option, + pub email: Option, +} + +async_handler!(AccountByIdentity, account_by_identity, FullAccount); + +async fn account_by_identity(msg: AccountByIdentity, db: PgPool) -> Result { + match (msg.login, msg.email) { + (Some(login), None) => sqlx::query_as( + r#" +SELECT id, email, login, pass_hash, role +FROM accounts +WHERE login = $1 + "#, + ) + .bind(login), + (None, Some(email)) => sqlx::query_as( + r#" +SELECT id, email, login, pass_hash, role +FROM accounts +WHERE email = $1 + "#, + ) + .bind(email), + (Some(login), Some(email)) => sqlx::query_as( + r#" +SELECT id, email, login, pass_hash, role +FROM accounts +WHERE login = $1 AND email = $2 + "#, + ) + .bind(login) + .bind(email), + _ => return Err(super::Error::Account(Error::NoIdentity)), + } + .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/api/src/actors/database/products.rs similarity index 100% rename from web/src/actors/database/products.rs rename to api/src/actors/database/products.rs diff --git a/api/src/actors/database/stocks.rs b/api/src/actors/database/stocks.rs new file mode 100644 index 0000000..bb1307b --- /dev/null +++ b/api/src/actors/database/stocks.rs @@ -0,0 +1,128 @@ +use actix::{Handler, Message, ResponseActFuture, WrapFuture}; +use sqlx::PgPool; + +use super::Result; +use crate::database::Database; +use crate::model::{ProductId, Quantity, QuantityUnit, Stock, StockId}; +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 AllStocks; + +crate::async_handler!(AllStocks, all, Vec); + +async fn all(_msg: AllStocks, pool: PgPool) -> Result> { + sqlx::query_as( + r#" +SELECT id, product_id, quantity, quantity_unit +FROM stocks + "#, + ) + .fetch_all(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Stock(Error::All) + }) +} + +#[derive(Message)] +#[rtype(result = "Result")] +pub struct CreateStock { + pub product_id: ProductId, + pub quantity: Quantity, + pub quantity_unit: QuantityUnit, +} + +crate::async_handler!(CreateStock, create_product, Stock); + +async fn create_product(msg: CreateStock, pool: PgPool) -> Result { + sqlx::query_as( + r#" +INSERT INTO stocks (product_id, quantity) +VALUES ($1, $2, $3) +RETURNING id, product_id, quantity, quantity_unit + "#, + ) + .bind(msg.product_id) + .bind(msg.quantity) + .bind(msg.quantity_unit) + .fetch_one(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Stock(Error::Create) + }) +} + +#[derive(Message)] +#[rtype(result = "Result")] +pub struct UpdateStock { + pub id: StockId, + pub product_id: ProductId, + pub quantity: Quantity, + pub quantity_unit: QuantityUnit, +} + +crate::async_handler!(UpdateStock, update_product, Stock); + +async fn update_product(msg: UpdateStock, pool: PgPool) -> Result { + sqlx::query_as( + r#" +UPDATE stocks +SET product_id = $1 AND + quantity = $2 + quantity_unit = $3 +WHERE id = $4 +RETURNING id, product_id, quantity, quantity_unit + "#, + ) + .bind(msg.product_id) + .bind(msg.quantity) + .bind(msg.quantity_unit) + .bind(msg.id) + .fetch_one(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Stock(Error::Update) + }) +} + +#[derive(Message)] +#[rtype(result = "Result>")] +pub struct DeleteStock { + pub stock_id: StockId, +} + +crate::async_handler!(DeleteStock, delete_product, Option); + +async fn delete_product(msg: DeleteStock, pool: PgPool) -> Result> { + sqlx::query_as( + r#" +DELETE FROM stocks +WHERE id = $1 +RETURNING id, product_id, quantity, quantity_unit + "#, + ) + .bind(msg.stock_id) + .fetch_optional(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Stock(Error::Delete) + }) +} diff --git a/web/src/actors/mod.rs b/api/src/actors/mod.rs similarity index 100% rename from web/src/actors/mod.rs rename to api/src/actors/mod.rs diff --git a/api/src/logic/mod.rs b/api/src/logic/mod.rs new file mode 100644 index 0000000..30c532b --- /dev/null +++ b/api/src/logic/mod.rs @@ -0,0 +1,23 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; + +use crate::model::Password; +use crate::PassHash; + +mod order_state; + +pub fn encrypt_password(pass: &Password, salt: &SaltString) -> password_hash::Result { + log::debug!("Hashing password {:?}", pass); + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()) + .hash_password(pass.as_bytes(), &salt)? + .to_string()) +} + +pub fn validate_password(pass: &Password, pass_hash: &PassHash) -> password_hash::Result<()> { + log::debug!("Validating password {:?} {:?}", pass, pass_hash); + + Argon2::default().verify_password( + pass.as_bytes(), + &PasswordHash::new(pass_hash.as_str()).expect("Invalid hashed password"), + ) +} diff --git a/web/src/logic/order_state.rs b/api/src/logic/order_state.rs similarity index 100% rename from web/src/logic/order_state.rs rename to api/src/logic/order_state.rs diff --git a/web/src/main.rs b/api/src/main.rs similarity index 86% rename from web/src/main.rs rename to api/src/main.rs index 5c73995..4be3e72 100644 --- a/web/src/main.rs +++ b/api/src/main.rs @@ -11,14 +11,24 @@ use password_hash::SaltString; use validator::{validate_email, validate_length}; use crate::actors::database; -use crate::logic::hash_pass; -use crate::model::{Email, Login, PassHash, Role}; +use crate::logic::encrypt_password; +use crate::model::{Email, Login, PassHash, Password, Role}; pub mod actors; pub mod logic; pub mod model; pub mod routes; +trait ResolveDbUrl { + fn own_db_url(&self) -> Option; + + fn db_url(&self) -> String { + self.own_db_url() + .or_else(|| std::env::var("DATABASE_URL").ok()) + .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) + } +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Failed to boot. {0:?}")] @@ -77,13 +87,9 @@ impl Default for ServerOpts { } } -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")) +impl ResolveDbUrl for ServerOpts { + fn own_db_url(&self) -> Option { + self.db_url.as_deref().map(String::from) } } @@ -93,13 +99,9 @@ struct MigrateOpts { 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")) +impl ResolveDbUrl for MigrateOpts { + fn own_db_url(&self) -> Option { + self.db_url.as_deref().map(String::from) } } @@ -129,13 +131,9 @@ struct CreateAccountDefinition { 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")) +impl ResolveDbUrl for CreateAccountDefinition { + fn own_db_url(&self) -> Option { + self.db_url.as_deref().map(String::from) } } @@ -163,6 +161,7 @@ async fn server(opts: ServerOpts) -> Result<()> { App::new() .wrap(Logger::default()) .wrap(actix_web::middleware::Compress::default()) + .wrap(actix_web::middleware::NormalizePath::default()) .wrap(SessionMiddleware::new( RedisActorSessionStore::new(redis_connection_string), secret_key.clone(), @@ -170,7 +169,7 @@ async fn server(opts: ServerOpts) -> Result<()> { .app_data(Data::new(config.clone())) .app_data(Data::new(db.clone())) .configure(routes::configure) - .default_service(web::to(HttpResponse::Ok)) + // .default_service(web::to(HttpResponse::Ok)) }) .bind((opts.bind, opts.port)) .map_err(Error::Boot)? @@ -209,11 +208,14 @@ async fn create_account(opts: CreateAccountOpts) -> Result<()> { None => { let mut s = String::with_capacity(100); std::io::stdin().read_line(&mut s).map_err(Error::ReadPass)?; + if let Some(pos) = s.chars().position(|c| c == '\n') { + s.remove(pos); + } s } }; let config = Config::load(); - let hash = hash_pass(&pass, &config.pass_salt).unwrap(); + let hash = encrypt_password(&Password(pass), &config.pass_salt).unwrap(); db.send(database::CreateAccount { email: Email(opts.email), diff --git a/web/src/model.rs b/api/src/model.rs similarity index 80% rename from web/src/model.rs rename to api/src/model.rs index 85bdca3..27c9ae5 100644 --- a/web/src/model.rs +++ b/api/src/model.rs @@ -1,12 +1,13 @@ use std::fmt::Formatter; -use derive_more::Display; +use derive_more::{Deref, Display}; use serde::de::{Error, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; pub type RecordId = i32; #[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] +#[sqlx(rename_all = "lowercase")] pub enum OrderStatus { #[display(fmt = "Potwierdzone")] Confirmed, @@ -23,6 +24,7 @@ pub enum OrderStatus { } #[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] +#[sqlx(rename_all = "lowercase")] pub enum Role { #[display(fmt = "Adminitrator")] Admin, @@ -30,12 +32,36 @@ pub enum Role { User, } -#[derive(sqlx::Type, Deserialize, Serialize)] +#[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] +#[sqlx(rename_all = "lowercase")] +pub enum QuantityUnit { + Gram, + Decagram, + Kilogram, + Unit, +} + +#[derive(sqlx::Type, Serialize, Deserialize, Deref)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct PriceMajor(NonNegative); + +#[derive(sqlx::Type, Serialize, Deserialize, Deref)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct PriceMinor(NonNegative); + +#[derive(sqlx::Type, Serialize, Deserialize, Deref)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct Quantity(NonNegative); + +#[derive(sqlx::Type, Deserialize, Serialize, Deref, Debug)] #[sqlx(transparent)] #[serde(transparent)] pub struct Login(pub String); -#[derive(sqlx::Type, Serialize)] +#[derive(sqlx::Type, Serialize, Deref, Debug)] #[sqlx(transparent)] #[serde(transparent)] pub struct Email(pub String); @@ -69,7 +95,7 @@ impl<'de> serde::Deserialize<'de> for Email { } } -#[derive(sqlx::Type, Serialize)] +#[derive(sqlx::Type, Serialize, Deref)] #[sqlx(transparent)] #[serde(transparent)] pub struct NonNegative(pub i32); @@ -103,17 +129,17 @@ impl<'de> serde::Deserialize<'de> for NonNegative { } } -#[derive(sqlx::Type, Serialize, Deserialize)] +#[derive(sqlx::Type, Serialize, Deserialize, Deref, Debug)] #[sqlx(transparent)] #[serde(transparent)] pub struct Password(pub String); -#[derive(sqlx::Type, Serialize, Deserialize)] +#[derive(sqlx::Type, Serialize, Deserialize, Deref, Debug)] #[sqlx(transparent)] #[serde(transparent)] pub struct PasswordConfirmation(pub String); -#[derive(sqlx::Type, Serialize, Deserialize)] +#[derive(sqlx::Type, Serialize, Deserialize, Deref, Debug)] #[sqlx(transparent)] #[serde(transparent)] pub struct PassHash(pub String); @@ -124,7 +150,7 @@ impl PartialEq for Password { } } -#[derive(sqlx::Type, Serialize, Deserialize)] +#[derive(sqlx::Type, Serialize, Deserialize, Deref)] #[sqlx(transparent)] #[serde(transparent)] pub struct AccountId(pub RecordId); @@ -177,16 +203,6 @@ pub struct ProductLongDesc(pub String); #[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, @@ -197,3 +213,16 @@ pub struct Product { pub price_major: PriceMajor, pub price_minor: PriceMinor, } + +#[derive(sqlx::Type, Serialize, Deserialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct StockId(pub RecordId); + +#[derive(sqlx::FromRow, Serialize, Deserialize)] +pub struct Stock { + pub id: StockId, + pub product_id: ProductId, + pub quantity: Quantity, + pub quantity_unit: QuantityUnit, +} diff --git a/api/src/routes/admin/api_v1.rs b/api/src/routes/admin/api_v1.rs new file mode 100644 index 0000000..5899ee7 --- /dev/null +++ b/api/src/routes/admin/api_v1.rs @@ -0,0 +1,8 @@ +mod products; +mod stocks; + +use actix_web::web::{scope, ServiceConfig}; + +pub fn configure(config: &mut ServiceConfig) { + config.service(scope("/api/v1").configure(products::configure).configure(stocks::configure)); +} diff --git a/api/src/routes/admin/api_v1/products.rs b/api/src/routes/admin/api_v1/products.rs new file mode 100644 index 0000000..f4e5700 --- /dev/null +++ b/api/src/routes/admin/api_v1/products.rs @@ -0,0 +1,111 @@ +use crate::database; +use crate::database::Database; +use crate::model::{ + PriceMajor, PriceMinor, ProductCategory, ProductId, ProductLongDesc, ProductName, + ProductShortDesc, +}; +use crate::routes::admin::Error; +use crate::routes::RequireLogin; +use crate::{admin_send_db, routes}; + +use actix::Addr; +use actix_session::Session; +use actix_web::web::{Data, Json, ServiceConfig}; +use actix_web::{delete, get, patch, post, HttpResponse}; +use serde::Deserialize; + +#[get("products")] +async fn products(session: Session, db: Data>) -> routes::Result { + session.require_admin()?; + + admin_send_db!(db, database::AllProducts); +} + +#[derive(Deserialize)] +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, +} + +#[patch("product")] +async fn update_product( + session: Session, + db: Data>, + Json(payload): Json, +) -> routes::Result { + session.require_admin()?; + + admin_send_db!( + db, + database::UpdateProduct { + id: payload.id, + name: payload.name, + short_description: payload.short_description, + long_description: payload.long_description, + category: payload.category, + price_major: payload.price_major, + price_minor: payload.price_minor, + } + ); +} + +#[derive(Deserialize)] +pub struct CreateProduct { + 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, +} + +#[post("product")] +async fn create_product( + session: Session, + db: Data>, + Json(payload): Json, +) -> routes::Result { + session.require_admin()?; + + admin_send_db!( + db, + database::CreateProduct { + name: payload.name, + short_description: payload.short_description, + long_description: payload.long_description, + category: payload.category, + price_major: payload.price_major, + price_minor: payload.price_minor, + } + ); +} + +#[derive(Deserialize)] +pub struct DeleteProduct { + pub id: ProductId, +} + +#[delete("product")] +async fn delete_product( + session: Session, + db: Data>, + Json(payload): Json, +) -> routes::Result { + session.require_admin()?; + + admin_send_db!(db, database::DeleteProduct { product_id: payload.id }); +} + +pub fn configure(config: &mut ServiceConfig) { + config + .service(products) + .service(update_product) + .service(create_product) + .service(delete_product); +} diff --git a/api/src/routes/admin/api_v1/stocks.rs b/api/src/routes/admin/api_v1/stocks.rs new file mode 100644 index 0000000..f10ca0b --- /dev/null +++ b/api/src/routes/admin/api_v1/stocks.rs @@ -0,0 +1,92 @@ +use crate::database; +use crate::database::Database; +use crate::model::{ProductId, Quantity, QuantityUnit, StockId}; +use crate::routes::admin::Error; +use crate::routes::RequireLogin; +use crate::{admin_send_db, routes}; + +use actix::Addr; +use actix_session::Session; +use actix_web::web::{Data, Json, ServiceConfig}; +use actix_web::{delete, get, patch, post, HttpResponse}; +use serde::Deserialize; + +#[get("stocks")] +async fn stocks(session: Session, db: Data>) -> routes::Result { + session.require_admin()?; + + admin_send_db!(db, database::AllStocks); +} + +#[derive(Deserialize)] +pub struct UpdateStock { + pub id: StockId, + pub product_id: ProductId, + pub quantity: Quantity, + pub quantity_unit: QuantityUnit, +} + +#[patch("stock")] +async fn update_stock( + session: Session, + db: Data>, + Json(payload): Json, +) -> routes::Result { + session.require_admin()?; + + admin_send_db!( + db, + database::UpdateStock { + id: payload.id, + product_id: payload.product_id, + quantity: payload.quantity, + quantity_unit: payload.quantity_unit + } + ); +} + +#[derive(Deserialize)] +pub struct CreateStock { + pub id: StockId, + pub product_id: ProductId, + pub quantity: Quantity, + pub quantity_unit: QuantityUnit, +} + +#[post("stock")] +async fn create_stock( + session: Session, + db: Data>, + Json(payload): Json, +) -> routes::Result { + session.require_admin()?; + + admin_send_db!( + db, + database::CreateStock { + product_id: payload.product_id, + quantity: payload.quantity, + quantity_unit: payload.quantity_unit + } + ); +} + +#[derive(Deserialize)] +pub struct DeleteStock { + pub id: StockId, +} + +#[delete("stock")] +async fn delete_stock( + session: Session, + db: Data>, + Json(payload): Json, +) -> routes::Result { + session.require_admin()?; + + admin_send_db!(db, database::DeleteStock { stock_id: payload.id }); +} + +pub fn configure(config: &mut ServiceConfig) { + config.service(stocks).service(create_stock).service(update_stock).service(delete_stock); +} diff --git a/web/src/routes/admin/mod.rs b/api/src/routes/admin/mod.rs similarity index 50% rename from web/src/routes/admin/mod.rs rename to api/src/routes/admin/mod.rs index 4c1622f..50690d2 100644 --- a/web/src/routes/admin/mod.rs +++ b/api/src/routes/admin/mod.rs @@ -1,15 +1,35 @@ +mod api_v1; + use actix::Addr; use actix_session::Session; -use actix_web::web::{Data, Json, ServiceConfig}; +use actix_web::web::{scope, 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::database::{AccountByIdentity, Database}; +use crate::logic::encrypt_password; use crate::model::{Account, Email, Login, PassHash, Password, PasswordConfirmation, Role}; use crate::routes::{RequireLogin, Result}; -use crate::{database, routes, Config}; +use crate::{database, model, routes, Config}; + +#[macro_export] +macro_rules! admin_send_db { + ($db: expr, $msg: expr) => {{ + let db = $db; + return match db.send($msg).await { + Ok(Ok(res)) => Ok(HttpResponse::Ok().json(res)), + Ok(Err(e)) => { + log::error!("{}", e); + Err(crate::routes::Error::Admin(Error::Database(e))) + } + Err(e) => { + log::error!("{}", e); + Err(crate::routes::Error::Admin(Error::DatabaseConnection)) + } + }; + }}; +} #[derive(Debug, thiserror::Error)] pub enum Error { @@ -26,15 +46,50 @@ pub enum Error { #[derive(Serialize)] pub struct LogoutResponse {} -#[delete("/admin/logout")] +#[delete("logout")] async fn logout(session: Session) -> Result { session.require_admin()?; + session.clear(); + Ok(HttpResponse::NotImplemented().body("")) } -#[post("/admin/sign-in")] -async fn sign_in(_session: Session) -> Result { - Ok(HttpResponse::NotImplemented().body("")) +#[derive(Deserialize, Debug)] +pub struct SignInInput { + login: Option, + email: Option, + password: Password, +} + +#[post("sign-in")] +async fn sign_in( + session: Session, + db: Data>, + Json(payload): Json, +) -> Result { + log::debug!("{:?}", payload); + let db = db.into_inner(); + let user: model::FullAccount = + match db.send(AccountByIdentity { email: payload.email, login: payload.login }).await { + Ok(Ok(user)) => user, + Ok(Err(e)) => { + log::error!("{}", e); + return Err(routes::Error::Unauthorized); + } + Err(e) => { + log::error!("{}", e); + return Err(routes::Error::Unauthorized); + } + }; + if let Err(e) = crate::logic::validate_password(&payload.password, &user.pass_hash) { + log::error!("Password validation failed. {}", e); + Err(routes::Error::Unauthorized) + } else { + if let Err(e) = session.insert("admin_id", *user.id) { + log::error!("{:?}", e); + } + Ok(HttpResponse::Ok().json(model::Account::from(user))) + } } #[derive(Deserialize)] @@ -59,7 +114,7 @@ pub enum RegisterError { } // login_required -#[post("/admin/register")] +#[post("register")] async fn register( session: Session, Json(input): Json, @@ -73,7 +128,7 @@ async fn register( response.errors.push(RegisterError::PasswordDiffer); } - let hash = match hash_pass(&input.password.0, &config.pass_salt) { + let hash = match encrypt_password(&input.password, &config.pass_salt) { Ok(s) => s, Err(e) => { log::error!("{e:?}"); @@ -105,34 +160,27 @@ async fn register( response.success = response.errors.is_empty(); Ok(if response.success { - HttpResponse::NotImplemented().json(response) + HttpResponse::Ok().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("")) + Ok(HttpResponse::NotImplemented() + .append_header(("Content-Type", "text/html")) + .body(include_str!("../../../assets/index.html"))) } pub fn configure(config: &mut ServiceConfig) { - config.service(landing).service(sign_in).service(logout).service(register); + config + .service( + scope("/admin") + .service(sign_in) + .service(logout) + .service(register) + .service(actix_web::web::scope("/api/v1").configure(api_v1::configure)), + ) + .service(landing); } diff --git a/web/src/routes/mod.rs b/api/src/routes/mod.rs similarity index 93% rename from web/src/routes/mod.rs rename to api/src/routes/mod.rs index e78046c..b5cbf8b 100644 --- a/web/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -17,7 +17,10 @@ impl RequireLogin for Session { fn require_admin(&self) -> Result { match self.get("admin_id") { Ok(Some(id)) => Ok(id), - _ => Err(Error::Unauthorized), + _ => { + log::debug!("User is not logged as admin"); + Err(Error::Unauthorized) + } } } } diff --git a/api/src/routes/public.rs b/api/src/routes/public.rs new file mode 100644 index 0000000..05dcf92 --- /dev/null +++ b/api/src/routes/public.rs @@ -0,0 +1,31 @@ +mod api_v1; + +use actix_web::web::ServiceConfig; +use actix_web::{get, HttpResponse}; + +#[macro_export] +macro_rules! public_send_db { + ($db: expr, $msg: expr) => {{ + let db = $db; + return match db.send($msg).await { + Ok(Ok(res)) => Ok(HttpResponse::Ok().json(res)), + Ok(Err(e)) => { + log::error!("{}", e); + Err(crate::routes::Error::Admin(Error::Database(e))) + } + Err(e) => { + log::error!("{}", e); + Err(crate::routes::Error::Admin(Error::DatabaseConnection)) + } + }; + }}; +} + +#[get("/")] +async fn landing() -> HttpResponse { + HttpResponse::NotImplemented().body("") +} + +pub fn configure(config: &mut ServiceConfig) { + config.service(landing).configure(api_v1::configure); +} diff --git a/api/src/routes/public/api_v1.rs b/api/src/routes/public/api_v1.rs new file mode 100644 index 0000000..eaf420a --- /dev/null +++ b/api/src/routes/public/api_v1.rs @@ -0,0 +1,23 @@ +use actix::Addr; +use actix_web::web::{scope, Data, ServiceConfig}; +use actix_web::{get, HttpResponse}; + +use crate::database; +use crate::database::Database; +use crate::public_send_db; +use crate::routes::admin::Error; +use crate::routes::Result; + +#[get("products")] +async fn products(db: Data>) -> Result { + public_send_db!(db.into_inner(), database::AllProducts) +} + +#[get("stocks")] +async fn stocks(db: Data>) -> Result { + public_send_db!(db.into_inner(), database::AllStocks) +} + +pub fn configure(config: &mut ServiceConfig) { + config.service(scope("/api/v1").service(products).service(stocks)); +} diff --git a/db/migrate/202204131841_init.sql b/db/migrate/202204131841_init.sql index d0b71ef..0dc6d13 100644 --- a/db/migrate/202204131841_init.sql +++ b/db/migrate/202204131841_init.sql @@ -1,17 +1,24 @@ CREATE EXTENSION "uuid-ossp"; CREATE TYPE "Role" AS ENUM ( - 'Admin', - 'User' + 'admin', + 'user' ); CREATE TYPE "OrderStatus" AS ENUM ( - 'Confirmed', - 'Cancelled', - 'Delivered', - 'Payed', - 'RequireRefund', - 'Refunded' + 'confirmed', + 'cancelled', + 'delivered', + 'payed', + 'require_refund', + 'refunded' + ); + +CREATE TYPE "QuantityUnit" AS ENUM ( + 'g', + 'dkg', + 'kg', + 'piece' ); CREATE TABLE accounts @@ -20,7 +27,7 @@ CREATE TABLE accounts email varchar not null unique, login varchar not null unique, pass_hash varchar not null, - role "Role" not null default 'User' + role "Role" not null default 'user' ); CREATE TABLE products @@ -37,9 +44,10 @@ CREATE TABLE products CREATE TABLE stocks ( - id serial not null primary key, - product_id int references products (id) not null unique, - quantity int not null default 0, + id serial not null primary key, + product_id int references products (id) not null unique, + quantity int not null default 0, + quantity_unit "QuantityUnit" not null, CONSTRAINT positive_quantity check ( quantity >= 0 ) ); @@ -47,15 +55,16 @@ CREATE TABLE account_orders ( id serial not null primary key, buyer_id int references accounts (id) not null, - status "OrderStatus" not null default 'Confirmed' + status "OrderStatus" not null default 'confirmed' ); CREATE TABLE order_items ( - id serial not null primary key, - product_id int references products (id) not null, - order_id int references account_orders (id), - quantity int not null default 0, + id serial not null primary key, + product_id int references products (id) not null, + order_id int references account_orders (id), + quantity int not null default 0, + quantity_unit "QuantityUnit" not null, CONSTRAINT positive_quantity check ( quantity >= 0 ) ); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9418d72 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Bazzar + + +
+
+ + diff --git a/web/src/actors/database/accounts.rs b/web/src/actors/database/accounts.rs deleted file mode 100644 index 762d0fe..0000000 --- a/web/src/actors/database/accounts.rs +++ /dev/null @@ -1,83 +0,0 @@ -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/logic/mod.rs b/web/src/logic/mod.rs deleted file mode 100644 index 67119cf..0000000 --- a/web/src/logic/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -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/routes/public.rs b/web/src/routes/public.rs deleted file mode 100644 index 69afa18..0000000 --- a/web/src/routes/public.rs +++ /dev/null @@ -1,11 +0,0 @@ -use actix_web::web::ServiceConfig; -use actix_web::{get, HttpResponse}; - -#[get("/")] -async fn landing() -> HttpResponse { - HttpResponse::NotImplemented().body("") -} - -pub fn configure(config: &mut ServiceConfig) { - config.service(landing); -}