Add endpoints, creating accounts and so on

This commit is contained in:
Adrian Woźniak 2022-04-15 17:04:23 +02:00
parent 93fc994d1d
commit d2634598d5
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
26 changed files with 798 additions and 198 deletions

1
.env
View File

@ -1,2 +1,3 @@
DATABASE_URL=postgres://postgres@localhost/bazzar DATABASE_URL=postgres://postgres@localhost/bazzar
PASS_SALT=18CHwV7eGFAea16z+qMKZg PASS_SALT=18CHwV7eGFAea16z+qMKZg
RUST_LOG=debug

View File

@ -1,2 +1,2 @@
[workspace] [workspace]
members = ["web"] members = ["api"]

View File

62
api/assets/index.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bazzar</title>
</head>
<body>
<div style="display: flex">
<div>
<form>
<select id="method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
<input id="path" type="text">
<textarea id="params"></textarea>
<input type="submit">
</form>
</div>
<div>
<pre><code id="output"></code></pre>
</div>
</div>
<script>
const out = document.querySelector('#output');
const form = document.querySelector("form");
const urlEl = form.querySelector('#path');
const paramsEl = form.querySelector('#params');
const mthEl = form.querySelector('#method');
form.addEventListener('submit', (ev) => {
ev.preventDefault();
ev.stopPropagation();
let path = urlEl.value;
let params = {};
const method = mthEl.value;
paramsEl.textContent.split("\n").forEach(s => {
if (!s.length) return;
let [k, v] = s.split("=");
params[k] = v;
});
const rest = method === 'GET'
? {}
: { body: JSON.stringify(params), headers: { 'Content-Type': 'application/json' } };
path = method === 'GET'
? `${ path }?${ JSON.stringify(params) }`
: path;
fetch(`/${ path }`, { ...rest, method })
.then(res => res.json())
.then(json => {
out.textContent = JSON.stringify(json, null, 4);
})
});
</script>
</body>
</html>

View File

@ -3,9 +3,11 @@ use sqlx::PgPool;
pub use accounts::*; pub use accounts::*;
pub use products::*; pub use products::*;
pub use stocks::*;
mod accounts; mod accounts;
mod products; mod products;
mod stocks;
#[macro_export] #[macro_export]
macro_rules! async_handler { macro_rules! async_handler {
@ -29,6 +31,8 @@ pub enum Error {
Account(accounts::Error), Account(accounts::Error),
#[error("{0}")] #[error("{0}")]
Product(products::Error), Product(products::Error),
#[error("{0}")]
Stock(stocks::Error),
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View File

@ -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<FullAccount>")]
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<FullAccount> {
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<FullAccount>")]
pub struct FindAccount {
pub account_id: AccountId,
}
async_handler!(FindAccount, find_account, FullAccount);
async fn find_account(msg: FindAccount, db: PgPool) -> Result<FullAccount> {
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<FullAccount>")]
pub struct AccountByIdentity {
pub login: Option<Login>,
pub email: Option<Email>,
}
async_handler!(AccountByIdentity, account_by_identity, FullAccount);
async fn account_by_identity(msg: AccountByIdentity, db: PgPool) -> Result<FullAccount> {
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)
})
}

View File

@ -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<Vec<model::Stock>>")]
pub struct AllStocks;
crate::async_handler!(AllStocks, all, Vec<Stock>);
async fn all(_msg: AllStocks, pool: PgPool) -> Result<Vec<model::Stock>> {
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<model::Stock>")]
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<model::Stock> {
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<model::Stock>")]
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<model::Stock> {
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<Option<model::Stock>>")]
pub struct DeleteStock {
pub stock_id: StockId,
}
crate::async_handler!(DeleteStock, delete_product, Option<model::Stock>);
async fn delete_product(msg: DeleteStock, pool: PgPool) -> Result<Option<Stock>> {
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)
})
}

23
api/src/logic/mod.rs Normal file
View File

@ -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<String> {
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"),
)
}

View File

@ -11,14 +11,24 @@ use password_hash::SaltString;
use validator::{validate_email, validate_length}; use validator::{validate_email, validate_length};
use crate::actors::database; use crate::actors::database;
use crate::logic::hash_pass; use crate::logic::encrypt_password;
use crate::model::{Email, Login, PassHash, Role}; use crate::model::{Email, Login, PassHash, Password, Role};
pub mod actors; pub mod actors;
pub mod logic; pub mod logic;
pub mod model; pub mod model;
pub mod routes; pub mod routes;
trait ResolveDbUrl {
fn own_db_url(&self) -> Option<String>;
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)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Failed to boot. {0:?}")] #[error("Failed to boot. {0:?}")]
@ -77,13 +87,9 @@ impl Default for ServerOpts {
} }
} }
impl ServerOpts { impl ResolveDbUrl for ServerOpts {
fn db_url(&self) -> String { fn own_db_url(&self) -> Option<String> {
self.db_url self.db_url.as_deref().map(String::from)
.as_deref()
.map(String::from)
.or_else(|| std::env::var("DATABASE_URL").ok())
.unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar"))
} }
} }
@ -93,13 +99,9 @@ struct MigrateOpts {
db_url: Option<String>, db_url: Option<String>,
} }
impl MigrateOpts { impl ResolveDbUrl for MigrateOpts {
fn db_url(&self) -> String { fn own_db_url(&self) -> Option<String> {
self.db_url self.db_url.as_deref().map(String::from)
.as_deref()
.map(String::from)
.or_else(|| std::env::var("DATABASE_URL").ok())
.unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar"))
} }
} }
@ -129,13 +131,9 @@ struct CreateAccountDefinition {
db_url: Option<String>, db_url: Option<String>,
} }
impl CreateAccountDefinition { impl ResolveDbUrl for CreateAccountDefinition {
fn db_url(&self) -> String { fn own_db_url(&self) -> Option<String> {
self.db_url self.db_url.as_deref().map(String::from)
.as_deref()
.map(String::from)
.or_else(|| std::env::var("DATABASE_URL").ok())
.unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar"))
} }
} }
@ -163,6 +161,7 @@ async fn server(opts: ServerOpts) -> Result<()> {
App::new() App::new()
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(actix_web::middleware::Compress::default()) .wrap(actix_web::middleware::Compress::default())
.wrap(actix_web::middleware::NormalizePath::default())
.wrap(SessionMiddleware::new( .wrap(SessionMiddleware::new(
RedisActorSessionStore::new(redis_connection_string), RedisActorSessionStore::new(redis_connection_string),
secret_key.clone(), secret_key.clone(),
@ -170,7 +169,7 @@ async fn server(opts: ServerOpts) -> Result<()> {
.app_data(Data::new(config.clone())) .app_data(Data::new(config.clone()))
.app_data(Data::new(db.clone())) .app_data(Data::new(db.clone()))
.configure(routes::configure) .configure(routes::configure)
.default_service(web::to(HttpResponse::Ok)) // .default_service(web::to(HttpResponse::Ok))
}) })
.bind((opts.bind, opts.port)) .bind((opts.bind, opts.port))
.map_err(Error::Boot)? .map_err(Error::Boot)?
@ -209,11 +208,14 @@ async fn create_account(opts: CreateAccountOpts) -> Result<()> {
None => { None => {
let mut s = String::with_capacity(100); let mut s = String::with_capacity(100);
std::io::stdin().read_line(&mut s).map_err(Error::ReadPass)?; 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 s
} }
}; };
let config = Config::load(); 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 { db.send(database::CreateAccount {
email: Email(opts.email), email: Email(opts.email),

View File

@ -1,12 +1,13 @@
use std::fmt::Formatter; use std::fmt::Formatter;
use derive_more::Display; use derive_more::{Deref, Display};
use serde::de::{Error, Visitor}; use serde::de::{Error, Visitor};
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
pub type RecordId = i32; pub type RecordId = i32;
#[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] #[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)]
#[sqlx(rename_all = "lowercase")]
pub enum OrderStatus { pub enum OrderStatus {
#[display(fmt = "Potwierdzone")] #[display(fmt = "Potwierdzone")]
Confirmed, Confirmed,
@ -23,6 +24,7 @@ pub enum OrderStatus {
} }
#[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)] #[derive(sqlx::Type, Copy, Clone, Debug, Display, Deserialize, Serialize)]
#[sqlx(rename_all = "lowercase")]
pub enum Role { pub enum Role {
#[display(fmt = "Adminitrator")] #[display(fmt = "Adminitrator")]
Admin, Admin,
@ -30,12 +32,36 @@ pub enum Role {
User, 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)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct Login(pub String); pub struct Login(pub String);
#[derive(sqlx::Type, Serialize)] #[derive(sqlx::Type, Serialize, Deref, Debug)]
#[sqlx(transparent)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct Email(pub String); 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)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct NonNegative(pub i32); 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)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct Password(pub String); pub struct Password(pub String);
#[derive(sqlx::Type, Serialize, Deserialize)] #[derive(sqlx::Type, Serialize, Deserialize, Deref, Debug)]
#[sqlx(transparent)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct PasswordConfirmation(pub String); pub struct PasswordConfirmation(pub String);
#[derive(sqlx::Type, Serialize, Deserialize)] #[derive(sqlx::Type, Serialize, Deserialize, Deref, Debug)]
#[sqlx(transparent)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct PassHash(pub String); pub struct PassHash(pub String);
@ -124,7 +150,7 @@ impl PartialEq<PasswordConfirmation> for Password {
} }
} }
#[derive(sqlx::Type, Serialize, Deserialize)] #[derive(sqlx::Type, Serialize, Deserialize, Deref)]
#[sqlx(transparent)] #[sqlx(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub struct AccountId(pub RecordId); pub struct AccountId(pub RecordId);
@ -177,16 +203,6 @@ pub struct ProductLongDesc(pub String);
#[serde(transparent)] #[serde(transparent)]
pub struct ProductCategory(pub String); 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)] #[derive(sqlx::FromRow, Serialize, Deserialize)]
pub struct Product { pub struct Product {
pub id: ProductId, pub id: ProductId,
@ -197,3 +213,16 @@ pub struct Product {
pub price_major: PriceMajor, pub price_major: PriceMajor,
pub price_minor: PriceMinor, 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,
}

View File

@ -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));
}

View File

@ -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<Addr<Database>>) -> routes::Result<HttpResponse> {
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<ProductCategory>,
pub price_major: PriceMajor,
pub price_minor: PriceMinor,
}
#[patch("product")]
async fn update_product(
session: Session,
db: Data<Addr<Database>>,
Json(payload): Json<UpdateProduct>,
) -> routes::Result<HttpResponse> {
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<ProductCategory>,
pub price_major: PriceMajor,
pub price_minor: PriceMinor,
}
#[post("product")]
async fn create_product(
session: Session,
db: Data<Addr<Database>>,
Json(payload): Json<CreateProduct>,
) -> routes::Result<HttpResponse> {
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<Addr<Database>>,
Json(payload): Json<DeleteProduct>,
) -> routes::Result<HttpResponse> {
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);
}

View File

@ -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<Addr<Database>>) -> routes::Result<HttpResponse> {
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<Addr<Database>>,
Json(payload): Json<UpdateStock>,
) -> routes::Result<HttpResponse> {
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<Addr<Database>>,
Json(payload): Json<CreateStock>,
) -> routes::Result<HttpResponse> {
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<Addr<Database>>,
Json(payload): Json<DeleteStock>,
) -> routes::Result<HttpResponse> {
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);
}

View File

@ -1,15 +1,35 @@
mod api_v1;
use actix::Addr; use actix::Addr;
use actix_session::Session; 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 actix_web::{delete, get, post, HttpResponse};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use crate::database::Database; use crate::database::{AccountByIdentity, Database};
use crate::logic::hash_pass; use crate::logic::encrypt_password;
use crate::model::{Account, Email, Login, PassHash, Password, PasswordConfirmation, Role}; use crate::model::{Account, Email, Login, PassHash, Password, PasswordConfirmation, Role};
use crate::routes::{RequireLogin, Result}; 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)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
@ -26,15 +46,50 @@ pub enum Error {
#[derive(Serialize)] #[derive(Serialize)]
pub struct LogoutResponse {} pub struct LogoutResponse {}
#[delete("/admin/logout")] #[delete("logout")]
async fn logout(session: Session) -> Result<HttpResponse> { async fn logout(session: Session) -> Result<HttpResponse> {
session.require_admin()?; session.require_admin()?;
session.clear();
Ok(HttpResponse::NotImplemented().body("")) Ok(HttpResponse::NotImplemented().body(""))
} }
#[post("/admin/sign-in")] #[derive(Deserialize, Debug)]
async fn sign_in(_session: Session) -> Result<HttpResponse> { pub struct SignInInput {
Ok(HttpResponse::NotImplemented().body("")) login: Option<Login>,
email: Option<Email>,
password: Password,
}
#[post("sign-in")]
async fn sign_in(
session: Session,
db: Data<Addr<Database>>,
Json(payload): Json<SignInInput>,
) -> Result<HttpResponse> {
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)] #[derive(Deserialize)]
@ -59,7 +114,7 @@ pub enum RegisterError {
} }
// login_required // login_required
#[post("/admin/register")] #[post("register")]
async fn register( async fn register(
session: Session, session: Session,
Json(input): Json<RegisterInput>, Json(input): Json<RegisterInput>,
@ -73,7 +128,7 @@ async fn register(
response.errors.push(RegisterError::PasswordDiffer); 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, Ok(s) => s,
Err(e) => { Err(e) => {
log::error!("{e:?}"); log::error!("{e:?}");
@ -105,34 +160,27 @@ async fn register(
response.success = response.errors.is_empty(); response.success = response.errors.is_empty();
Ok(if response.success { Ok(if response.success {
HttpResponse::NotImplemented().json(response) HttpResponse::Ok().json(response)
} else { } else {
HttpResponse::BadRequest().json(response) HttpResponse::BadRequest().json(response)
}) })
} }
#[get("/admin/api/v1/products")]
async fn api_v1_products(session: Session, db: Data<Addr<Database>>) -> Result<HttpResponse> {
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")] #[get("/admin")]
async fn landing() -> Result<HttpResponse> { async fn landing() -> Result<HttpResponse> {
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) { 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);
} }

View File

@ -17,7 +17,10 @@ impl RequireLogin for Session {
fn require_admin(&self) -> Result<RecordId> { fn require_admin(&self) -> Result<RecordId> {
match self.get("admin_id") { match self.get("admin_id") {
Ok(Some(id)) => Ok(id), Ok(Some(id)) => Ok(id),
_ => Err(Error::Unauthorized), _ => {
log::debug!("User is not logged as admin");
Err(Error::Unauthorized)
}
} }
} }
} }

31
api/src/routes/public.rs Normal file
View File

@ -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);
}

View File

@ -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<Addr<Database>>) -> Result<HttpResponse> {
public_send_db!(db.into_inner(), database::AllProducts)
}
#[get("stocks")]
async fn stocks(db: Data<Addr<Database>>) -> Result<HttpResponse> {
public_send_db!(db.into_inner(), database::AllStocks)
}
pub fn configure(config: &mut ServiceConfig) {
config.service(scope("/api/v1").service(products).service(stocks));
}

View File

@ -1,17 +1,24 @@
CREATE EXTENSION "uuid-ossp"; CREATE EXTENSION "uuid-ossp";
CREATE TYPE "Role" AS ENUM ( CREATE TYPE "Role" AS ENUM (
'Admin', 'admin',
'User' 'user'
); );
CREATE TYPE "OrderStatus" AS ENUM ( CREATE TYPE "OrderStatus" AS ENUM (
'Confirmed', 'confirmed',
'Cancelled', 'cancelled',
'Delivered', 'delivered',
'Payed', 'payed',
'RequireRefund', 'require_refund',
'Refunded' 'refunded'
);
CREATE TYPE "QuantityUnit" AS ENUM (
'g',
'dkg',
'kg',
'piece'
); );
CREATE TABLE accounts CREATE TABLE accounts
@ -20,7 +27,7 @@ CREATE TABLE accounts
email varchar not null unique, email varchar not null unique,
login varchar not null unique, login varchar not null unique,
pass_hash varchar not null, pass_hash varchar not null,
role "Role" not null default 'User' role "Role" not null default 'user'
); );
CREATE TABLE products CREATE TABLE products
@ -37,9 +44,10 @@ CREATE TABLE products
CREATE TABLE stocks CREATE TABLE stocks
( (
id serial not null primary key, id serial not null primary key,
product_id int references products (id) not null unique, product_id int references products (id) not null unique,
quantity int not null default 0, quantity int not null default 0,
quantity_unit "QuantityUnit" not null,
CONSTRAINT positive_quantity check ( quantity >= 0 ) CONSTRAINT positive_quantity check ( quantity >= 0 )
); );
@ -47,15 +55,16 @@ CREATE TABLE account_orders
( (
id serial not null primary key, id serial not null primary key,
buyer_id int references accounts (id) not null, buyer_id int references accounts (id) not null,
status "OrderStatus" not null default 'Confirmed' status "OrderStatus" not null default 'confirmed'
); );
CREATE TABLE order_items CREATE TABLE order_items
( (
id serial not null primary key, id serial not null primary key,
product_id int references products (id) not null, product_id int references products (id) not null,
order_id int references account_orders (id), order_id int references account_orders (id),
quantity int not null default 0, quantity int not null default 0,
quantity_unit "QuantityUnit" not null,
CONSTRAINT positive_quantity check ( quantity >= 0 ) CONSTRAINT positive_quantity check ( quantity >= 0 )
); );

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bazzar</title>
</head>
<body>
<main>
</main>
</body>
</html>

View File

@ -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<FullAccount>")]
pub struct CreateAccount {
pub email: Email,
pub login: Login,
pub pass_hash: PassHash,
pub role: Role,
}
impl Handler<CreateAccount> for Database {
type Result = ResponseActFuture<Self, Result<FullAccount>>;
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<FullAccount> {
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<FullAccount>")]
pub struct FindAccount {
pub account_id: AccountId,
}
impl Handler<FindAccount> for Database {
type Result = ResponseActFuture<Self, Result<FullAccount>>;
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<FullAccount> {
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)
})
}

View File

@ -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<String> {
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)?)
}

View File

@ -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);
}