feat: bootstrap database ops

- db-core: defines base database traits that are required for gists
- db-sqlx-postgres: implements db-core for postgres flavor of the sqlx
  library
- db-sqlx-sqlite: implements db-core for sqlite flavor of the sqlx
  library
master
Aravinth Manivannan 2022-02-12 16:34:06 +05:30
parent 4858f050e7
commit b28a7d0cfb
29 changed files with 4409 additions and 25 deletions

3
.env-sample Normal file
View File

@ -0,0 +1,3 @@
export POSTGRES_DATABASE_URL="postgres://postgres:password@localhost:5432/postgres"
export SQLITE_TMP="$(pwd)/database/db-sqlx-sqlite/tmp"
export SQLITE_DATABASE_URL="sqlite://$SQLITE_TMP/admin.db"

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
.env

1390
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,11 +11,22 @@ license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
build = "build.rs"
[workspace]
exclude = ["database/migrator"]
members = [
".",
"database/db-core",
"database/db-sqlx-postgres",
"database/db-sqlx-sqlite",
]
[dependencies]
actix-web = "4.0.0-rc.3"
actix-http = "3.0.0-rc.2"
actix-rt = "2.6.0"
argon2-creds = { branch = "master", git = "https://github.com/realaravinth/argon2-creds"}
config = "0.11"
db-core = {path = "./database/db-core"}
derive_more = "0.99"
git2 = "0.13.25"
lazy_static = "1.4"
@ -23,6 +34,9 @@ log = "0.4"
my-codegen = {package = "actix-web-codegen", git ="https://github.com/realaravinth/actix-web"}
num_cpus = "1.13"
pretty_env_logger = "0.4"
rand = "0.8.4"
serde = { version = "1", features = ["derive"]}
serde_json = "1"
tokio = "1.16.1"
url = "2.2"
validator = { version = "0.14.0", features = ["derive"] }

View File

@ -4,8 +4,8 @@ default: ## Debug build
clean: ## Clean all build artifacts and dependencies
@cargo clean
coverage: ## Generate HTML code coverage
cargo tarpaulin -t 1200 --out Html
coverage: migrate ## Generate coverage report in HTML format
cargo tarpaulin -t 1200 --out Html --skip-clean --all-features --no-fail-fast --workspace=database/db-sqlx-postgres,database/db-sqlx-sqlite,.
dev-env: ## Download development dependencies
cargo fetch
@ -30,11 +30,21 @@ release: ## Release build
run: default ## Run debug build
cargo run
test: ## Run tests
cargo test --all-features --no-fail-fast
migrate: ## run migrations
@-rm -rf database/db-sqlx-sqlite/tmp && mkdir database/db-sqlx-sqlite/tmp
cd database/migrator && cargo run
xml-test-coverage: ## Generate cobertura.xml test coverage
cargo tarpaulin -t 1200 --out Xml
test: migrate ## Run tests
cd database/db-sqlx-postgres &&\
DATABASE_URL=${POSTGRES_DATABASE_URL}\
cargo test --no-fail-fast
cd database/db-sqlx-sqlite &&\
DATABASE_URL=${SQLITE_DATABASE_URL}\
cargo test --no-fail-fast
cargo test
xml-test-coverage: migrate ## Generate cobertura.xml test coverage
cargo tarpaulin -t 1200 --out Xml --skip-clean --all-features --no-fail-fast --workspace=database/db-sqlx-postgres,database/db-sqlx-sqlite,.
help: ## Prints help for targets with comments
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

2
database/db-core/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

View File

@ -0,0 +1,21 @@
[package]
name = "db-core"
version = "0.1.0"
edition = "2021"
homepage = "https://github.com/realaravinth/gists"
repository = "https://github.com/realaravinth/gists"
documentation = "https://github.con/realaravinth/gists"
readme = "https://github.com/realaravinth/gists/blob/master/README.md"
license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-trait = "0.1.51"
thiserror = "1.0.30"
[features]
default = []
test = []

View File

@ -0,0 +1,38 @@
//! represents all the ways a trait can fail using this crate
use std::error::Error as StdError;
//use derive_more::{error, Error as DeriveError};
use thiserror::Error;
/// Error data structure grouping various error subtypes
#[derive(Debug, Error)]
pub enum DBError {
/// username is already taken
#[error("Username not available")]
DuplicateUsername,
/// user secret is already taken
#[error("User secret not available")]
DuplicateSecret,
/// email is already taken
#[error("Email not available")]
DuplicateEmail,
/// Account with specified characteristics not found
#[error("Account with specified characteristics not found")]
AccountNotFound,
// /// errors that are specific to a database implementation
// #[error("Database error: {:?}", _0)]
// DBError(#[error(not(source))] String),
/// errors that are specific to a database implementation
#[error("{0}")]
DBError(#[source] BoxDynError),
}
/// Convenience type alias for grouping driver-specific errors
pub type BoxDynError = Box<dyn StdError + 'static + Send + Sync>;
/// Generic result data structure
pub type DBResult<V> = std::result::Result<V, DBError>;

172
database/db-core/src/lib.rs Normal file
View File

@ -0,0 +1,172 @@
#![warn(missing_docs)]
//! # `gists` database operations
//!
//! Traits and datastructures used in gists to interact with database.
//!
//! To use an unsupported database with gists, traits present within this crate should be
//! implemented.
//!
//!
//! ## Organisation
//!
//! Database functionallity is divided accross various modules:
//!
//! - [errors](crate::auth): error data structures used in this crate
//! - [ops](crate::ops): meta operations like connection pool creation, migrations and getting
//! connection from pool
pub mod errors;
pub mod ops;
#[cfg(feature = "test")]
pub mod tests;
pub use ops::GetConnection;
pub mod prelude {
//! useful imports for users working with a supported database
pub use super::errors::*;
pub use super::ops::*;
pub use super::*;
}
pub mod dev {
//! useful imports for supporting a new database
pub use super::prelude::*;
pub use async_trait::async_trait;
}
/// data structure describing credentials of a user
#[derive(Clone, Debug)]
pub struct Creds {
/// username
pub username: String,
/// password
pub password: String,
}
/// data structure containing only a password field
#[derive(Clone, Debug)]
pub struct Password {
/// password
pub password: String,
}
/// payload to register a user with username _and_ email
pub struct EmailRegisterPayload<'a> {
/// username of new user
pub username: &'a str,
/// password of new user
pub password: &'a str,
/// password of new user
pub email: &'a str,
/// a randomly generated secret associated with an account
pub secret: &'a str,
}
/// payload to register a user with only username
pub struct UsernameRegisterPayload<'a> {
/// username provided during registration
pub username: &'a str,
/// password of new user
pub password: &'a str,
/// a randomly generated secret associated with an account
pub secret: &'a str,
}
/// payload to update email in the database
#[derive(Clone, Debug)]
pub struct UpdateEmailPayload<'a> {
/// name of the user who's email is to be updated
pub username: &'a str,
/// new email
pub email: &'a str,
}
/// payload to update a username in database
pub struct UpdateUsernamePayload<'a> {
/// old usename
pub old_username: &'a str,
/// new username
pub new_username: &'a str,
}
use dev::*;
/// foo
#[async_trait]
pub trait GistDatabase: std::marker::Send + std::marker::Sync {
/// Update email of specified user in database
async fn update_email(&self, payload: &UpdateEmailPayload) -> DBResult<()>;
/// Update password of specified user in database
async fn update_password(&self, payload: &Creds) -> DBResult<()>;
/// check if an email exists in the database
async fn email_exists(&self, email: &str) -> DBResult<bool>;
/// delete account from database
async fn delete_account(&self, username: &str) -> DBResult<()>;
/// check if a username exists in the database
async fn username_exists(&self, username: &str) -> DBResult<bool>;
/// update username in database
async fn update_username(&self, payload: &UpdateUsernamePayload) -> DBResult<()>;
/// update secret in database
async fn update_secret(&self, username: &str, secret: &str) -> DBResult<()>;
/// update secret in database
async fn get_secret(&self, username: &str) -> DBResult<String>;
/// login with email as user-identifier
async fn email_login(&self, email: &str) -> DBResult<Creds>;
/// login with username as user-identifier
async fn username_login(&self, username: &str) -> DBResult<Password>;
/// username _and_ email is available during registration
async fn email_register(&self, payload: &EmailRegisterPayload) -> DBResult<()>;
/// register with username
async fn username_register(&self, payload: &UsernameRegisterPayload) -> DBResult<()>;
}
#[async_trait]
impl<T: GistDatabase + ?Sized> GistDatabase for Box<T> {
async fn update_email(&self, payload: &UpdateEmailPayload) -> DBResult<()> {
(**self).update_email(payload).await
}
/// Update password of specified user in database
async fn update_password(&self, payload: &Creds) -> DBResult<()> {
(**self).update_password(payload).await
}
/// check if an email exists in the database
async fn email_exists(&self, email: &str) -> DBResult<bool> {
(**self).email_exists(email).await
}
/// delete account from database
async fn delete_account(&self, username: &str) -> DBResult<()> {
(**self).delete_account(username).await
}
/// check if a username exists in the database
async fn username_exists(&self, username: &str) -> DBResult<bool> {
(**self).username_exists(username).await
}
/// update username in database
async fn update_username(&self, payload: &UpdateUsernamePayload) -> DBResult<()> {
(**self).update_username(payload).await
}
/// update secret in database
async fn update_secret(&self, username: &str, secret: &str) -> DBResult<()> {
(**self).update_secret(username, secret).await
}
/// update secret in database
async fn get_secret(&self, username: &str) -> DBResult<String> {
(**self).get_secret(username).await
}
/// login with email as user-identifier
async fn email_login(&self, email: &str) -> DBResult<Creds> {
(**self).email_login(email).await
}
/// login with username as user-identifier
async fn username_login(&self, username: &str) -> DBResult<Password> {
(**self).username_login(username).await
}
/// username _and_ email is available during registration
async fn email_register(&self, payload: &EmailRegisterPayload) -> DBResult<()> {
(**self).email_register(payload).await
}
/// register with username
async fn username_register(&self, payload: &UsernameRegisterPayload) -> DBResult<()> {
(**self).username_register(payload).await
}
}

View File

@ -0,0 +1,33 @@
//! meta operations like migration and connecting to a database
use crate::dev::*;
/// Database operations trait(migrations, pool creation and fetching connection from pool)
pub trait DBOps: GetConnection + Migrate {}
/// Get database connection
#[async_trait]
pub trait GetConnection {
/// database connection type
type Conn;
/// database specific error-type
/// get connection from connection pool
async fn get_conn(&self) -> DBResult<Self::Conn>;
}
/// Create databse connection
#[async_trait]
pub trait Connect {
/// database specific pool-type
type Pool: GistDatabase;
/// database specific error-type
/// create connection pool
async fn connect(self) -> DBResult<Self::Pool>;
}
/// database migrations
#[async_trait]
pub trait Migrate: GistDatabase {
/// database specific error-type
/// run migrations
async fn migrate(&self) -> DBResult<()>;
}

View File

@ -0,0 +1,151 @@
//! Test utilities
use crate::prelude::*;
/// test email registration implementation
pub async fn email_register_works<T: GistDatabase>(
db: &T,
email: &str,
username: &str,
password: &str,
secret: &str,
username2: &str,
) {
let _ = db.delete_account(username).await;
let _ = db.delete_account(username2).await;
assert!(matches!(
db.email_login(email).await.err(),
Some(DBError::AccountNotFound)
));
let mut register_payload = EmailRegisterPayload {
email,
username,
password,
secret,
};
db.email_register(&register_payload).await.unwrap();
assert!(db.username_exists(username).await.unwrap());
assert!(db.email_exists(email).await.unwrap());
assert_eq!(db.get_secret(username).await.unwrap(), secret);
let login_resp = db.email_login(email).await.unwrap();
assert_eq!(login_resp.username, username);
assert_eq!(login_resp.password, password);
register_payload.secret = email;
register_payload.username = username2;
let err = db.email_register(&register_payload).await.err();
assert!(matches!(err, Some(DBError::DuplicateEmail)));
}
/// test username registration implementation
pub async fn username_register_works<T: GistDatabase>(
db: &T,
username: &str,
password: &str,
secret: &str,
) {
let _ = db.delete_account(username).await;
assert!(matches!(
db.username_login(username).await.err(),
Some(DBError::AccountNotFound)
));
let mut register_payload = UsernameRegisterPayload {
username,
password,
secret,
};
db.username_register(&register_payload).await.unwrap();
assert!(db.username_exists(username).await.unwrap());
assert_eq!(db.get_secret(username).await.unwrap(), secret);
let login_resp = db.username_login(username).await.unwrap();
assert_eq!(login_resp.password, password);
register_payload.secret = username;
assert!(matches!(
db.username_register(&register_payload).await.err(),
Some(DBError::DuplicateUsername)
));
}
/// test duplicate secret errors
pub async fn duplicate_secret_guard_works<T: GistDatabase>(
db: &T,
username: &str,
password: &str,
username2: &str,
secret: &str,
duplicate_secret: &str,
) {
let _ = db.delete_account(username).await;
let _ = db.delete_account(username2).await;
let mut register_payload = UsernameRegisterPayload {
username,
password,
secret,
};
db.username_register(&register_payload).await.unwrap();
assert!(db.username_exists(username).await.unwrap());
assert_eq!(db.get_secret(username).await.unwrap(), secret);
register_payload.username = username2;
assert!(matches!(
db.username_register(&register_payload).await.err(),
Some(DBError::DuplicateSecret)
));
assert!(matches!(
db.update_secret(username, duplicate_secret).await.err(),
Some(DBError::DuplicateSecret)
));
db.update_secret(username, username).await.unwrap();
}
/// check if duplicate username and duplicate email guards are working on update workflows
pub async fn duplicate_username_and_email<T: GistDatabase>(
db: &T,
username: &str,
fresh_username: &str,
fresh_email: &str,
password: &str,
secret: &str,
duplicate_username: &str,
duplicate_email: &str,
) {
let _ = db.delete_account(username).await;
let _ = db.delete_account(fresh_username).await;
let register_payload = UsernameRegisterPayload {
username,
password,
secret,
};
db.username_register(&register_payload).await.unwrap();
let mut update_email_payload = UpdateEmailPayload {
username,
email: duplicate_email,
};
let err = db.update_email(&update_email_payload).await.err();
assert!(matches!(err, Some(DBError::DuplicateEmail)));
update_email_payload.email = fresh_email;
db.update_email(&update_email_payload).await.unwrap();
let mut update_username_payload = UpdateUsernamePayload {
new_username: duplicate_username,
old_username: username,
};
assert!(matches!(
db.update_username(&update_username_payload).await.err(),
Some(DBError::DuplicateUsername)
));
update_username_payload.new_username = fresh_username;
db.update_username(&update_username_payload).await.unwrap();
}

2
database/db-sqlx-postgres/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

View File

@ -0,0 +1,23 @@
[package]
name = "db-sqlx-postgres"
version = "0.1.0"
edition = "2021"
homepage = "https://github.com/realaravinth/gists"
repository = "https://github.com/realaravinth/gists"
documentation = "https://github.con/realaravinth/gists"
readme = "https://github.com/realaravinth/gists/blob/master/README.md"
license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
include = ["./mgrations/"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
db-core = {path = "../db-core"}
sqlx = { version = "0.5.10", features = [ "postgres", "time", "offline" ] }
async-trait = "0.1.51"
[dev-dependencies]
actix-rt = "2"
sqlx = { version = "0.5.10", features = [ "runtime-actix-rustls", "postgres", "time", "offline" ] }
db-core = {path = "../db-core", features = ["test"]}

View File

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS admin_users (
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) UNIQUE DEFAULT NULL,
email_verified BOOLEAN DEFAULT NULL,
secret varchar(50) NOT NULL UNIQUE,
password TEXT NOT NULL,
ID SERIAL PRIMARY KEY NOT NULL
);

View File

@ -0,0 +1,202 @@
{
"db": "PostgreSQL",
"08e90ce06e795bdb9bf0ea5b18df3b69b107764ad9d224de601b3588fbeac211": {
"query": "UPDATE admin_users set secret = $1\n WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
}
},
"150c8d182ca90bd50fdd419e5b1b2bb48c8eb5d060d7ab0207dfc04e8eda6fee": {
"query": "INSERT INTO admin_users \n (username , password, secret) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Varchar"
]
},
"nullable": []
}
},
"1c6e0ac5913665e512c5d6c8b99af61e6adbac6482de651ff8aeab4210ba4120": {
"query": "UPDATE admin_users set email = $1\n WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
}
},
"29f35f75a0ddedaabdfecd6a22ee16747f91b2b928641361fe43738eb21e7607": {
"query": "DELETE FROM admin_users WHERE username = ($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
}
},
"2f9e7d2c4e5335a1c221f8162f5c47c44efbea37cc41670873303f6892e62375": {
"query": "UPDATE admin_users set password = $1\n WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
}
},
"3e91a474c261bae1ca27f1a5fcf9a276b5fa000039f5118b6daf5640b8708894": {
"query": "SELECT secret FROM admin_users WHERE username = ($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "secret",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
}
},
"4899a9845675082afcfca09d016da313f09932882f5e71ca7da26db94aa14dc1": {
"query": "UPDATE admin_users set username = $1 WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
}
},
"524065157b7c8e0a2ff02eb8efd7ddf79f585b2f1428f9d2bab92b3d2b2b9b71": {
"query": "insert into admin_users \n (username , password, email, secret) values ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Varchar",
"Varchar"
]
},
"nullable": []
}
},
"583d7cf4a5a111471f7898bea0001c2e4c18200d8840456a3b28fedf6d7c1359": {
"query": "SELECT EXISTS (SELECT 1 from admin_users WHERE username = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
}
},
"677c618882d6b7e621f9f2b040671d8260518383796efad120a8ffdb596e9d7a": {
"query": "SELECT username, password FROM admin_users WHERE email = ($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "password",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
}
},
"9b53b26143e9583dac3454eafb34fdfb5976144b64cdc0504138b74f8b950ccd": {
"query": "SELECT EXISTS (SELECT 1 from admin_users WHERE email = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
}
},
"dadfd47b8d33b0b636a79716afa5c8afdf9873304ff9fe883de7eae3aa5e8504": {
"query": "SELECT password FROM admin_users WHERE username = ($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
}
}
}

View File

@ -0,0 +1,27 @@
//! Error-handling utilities
use std::borrow::Cow;
use db_core::dev::*;
use sqlx::Error;
/// map postgres errors to [DBError](DBError) types
pub fn map_register_err(e: Error) -> DBError {
if let Error::Database(err) = e {
if err.code() == Some(Cow::from("23505")) {
let msg = err.message();
if msg.contains("admin_users_username_key") {
DBError::DuplicateUsername
} else if msg.contains("admin_users_email_key") {
DBError::DuplicateEmail
} else if msg.contains("admin_users_secret_key") {
DBError::DuplicateSecret
} else {
DBError::DBError(Box::new(Error::Database(err)))
}
} else {
DBError::DBError(Box::new(Error::Database(err)))
}
} else {
DBError::DBError(Box::new(e))
}
}

View File

@ -0,0 +1,272 @@
#![deny(missing_docs)]
//! # `libadmin` database operations implemented using sqlx postgres
//!
//! [`GistDatabase`](GistDatabase) is implemented on [Database].
use db_core::dev::*;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
mod errors;
#[cfg(test)]
pub mod tests;
/// Database pool. All database functionallity(`libadmin` traits) are implemented on this
/// data structure
pub struct Database {
/// database pool
pub pool: PgPool,
}
/// Use an existing database pool
pub struct Conn(pub PgPool);
/// Connect to databse
pub enum ConnectionOptions {
/// fresh connection
Fresh(Fresh),
/// existing connection
Existing(Conn),
}
/// Create a new database pool
pub struct Fresh {
/// Pool options
pub pool_options: PgPoolOptions,
/// database URL
pub url: String,
}
pub mod dev {
//! useful imports for supporting a new database
pub use super::errors::*;
pub use super::Database;
pub use db_core::dev::*;
pub use prelude::*;
pub use sqlx::Error;
}
pub mod prelude {
//! useful imports for users working with a supported database
pub use super::*;
pub use db_core::prelude::*;
}
use dev::*;
#[async_trait]
impl Connect for ConnectionOptions {
type Pool = Database;
/// create connection pool
async fn connect(self) -> DBResult<Self::Pool> {
let pool = match self {
Self::Fresh(fresh) => fresh
.pool_options
.connect(&fresh.url)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?,
Self::Existing(conn) => conn.0,
};
Ok(Database { pool })
}
}
#[async_trait]
impl Migrate for Database {
async fn migrate(&self) -> DBResult<()> {
sqlx::migrate!("./migrations/")
.run(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
Ok(())
}
}
#[async_trait]
impl GistDatabase for Database {
async fn email_login(&self, email: &str) -> DBResult<Creds> {
sqlx::query_as!(
Creds,
r#"SELECT username, password FROM admin_users WHERE email = ($1)"#,
email,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
Error::RowNotFound => DBError::AccountNotFound,
e => DBError::DBError(Box::new(e)),
})
}
async fn username_login(&self, username: &str) -> DBResult<Password> {
sqlx::query_as!(
Password,
r#"SELECT password FROM admin_users WHERE username = ($1)"#,
username,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
Error::RowNotFound => DBError::AccountNotFound,
e => DBError::DBError(Box::new(e)),
})
}
async fn email_register(&self, payload: &EmailRegisterPayload) -> DBResult<()> {
sqlx::query!(
"insert into admin_users
(username , password, email, secret) values ($1, $2, $3, $4)",
&payload.username,
&payload.password,
&payload.email,
&payload.secret,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
Ok(())
}
async fn username_register(&self, payload: &UsernameRegisterPayload) -> DBResult<()> {
sqlx::query!(
"INSERT INTO admin_users
(username , password, secret) VALUES ($1, $2, $3)",
&payload.username,
&payload.password,
&payload.secret,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
Ok(())
}
async fn update_email(&self, payload: &UpdateEmailPayload) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set email = $1
WHERE username = $2",
&payload.email,
&payload.username,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn update_password(&self, payload: &Creds) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set password = $1
WHERE username = $2",
&payload.password,
&payload.username,
)
.execute(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn email_exists(&self, email: &str) -> DBResult<bool> {
let res = sqlx::query!(
"SELECT EXISTS (SELECT 1 from admin_users WHERE email = $1)",
&email
)
.fetch_one(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
let mut exists = false;
if let Some(x) = res.exists {
if x {
exists = true;
}
};
Ok(exists)
}
async fn delete_account(&self, username: &str) -> DBResult<()> {
sqlx::query!("DELETE FROM admin_users WHERE username = ($1)", username,)
.execute(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
Ok(())
}
async fn username_exists(&self, username: &str) -> DBResult<bool> {
let res = sqlx::query!(
"SELECT EXISTS (SELECT 1 from admin_users WHERE username = $1)",
&username
)
.fetch_one(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
let mut exists = false;
if let Some(x) = res.exists {
if x {
exists = true;
}
};
Ok(exists)
}
async fn update_username(&self, payload: &UpdateUsernamePayload) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set username = $1 WHERE username = $2",
&payload.new_username,
&payload.old_username,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn update_secret(&self, username: &str, secret: &str) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set secret = $1
WHERE username = $2",
secret,
username,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn get_secret(&self, username: &str) -> DBResult<String> {
struct Secret {
secret: String,
}
let secret = sqlx::query_as!(
Secret,
r#"SELECT secret FROM admin_users WHERE username = ($1)"#,
username,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
Error::RowNotFound => DBError::AccountNotFound,
e => DBError::DBError(Box::new(e)),
})?;
Ok(secret.secret)
}
}

View File

@ -0,0 +1,40 @@
use sqlx::postgres::PgPoolOptions;
use std::env;
use crate::*;
use db_core::tests::*;
#[actix_rt::test]
async fn everyting_works() {
const EMAIL: &str = "postgresuser@foo.com";
const EMAIL2: &str = "postgresuse2r@foo.com";
const NAME: &str = "postgresuser";
const NAME2: &str = "postgresuser2";
const NAME3: &str = "postgresuser3";
const NAME4: &str = "postgresuser4";
const NAME5: &str = "postgresuser5";
const NAME6: &str = "postgresuser6";
const NAME7: &str = "postgresuser7";
const PASSWORD: &str = "pasdfasdfasdfadf";
const SECRET1: &str = "postgressecret1";
const SECRET2: &str = "postgressecret2";
const SECRET3: &str = "postgressecret3";
const SECRET4: &str = "postgressecret4";
let url = env::var("POSTGRES_DATABASE_URL").unwrap();
let pool_options = PgPoolOptions::new().max_connections(2);
let connection_options = ConnectionOptions::Fresh(Fresh { pool_options, url });
let db = connection_options.connect().await.unwrap();
db.migrate().await.unwrap();
email_register_works(&db, EMAIL, NAME, PASSWORD, SECRET1, NAME5).await;
username_register_works(&db, NAME2, PASSWORD, SECRET2).await;
duplicate_secret_guard_works(&db, NAME3, PASSWORD, NAME4, SECRET3, SECRET2).await;
duplicate_username_and_email(&db, NAME6, NAME7, EMAIL2, PASSWORD, SECRET4, NAME, EMAIL).await;
let creds = Creds {
username: NAME.into(),
password: SECRET4.into(),
};
db.update_password(&creds).await.unwrap();
}

2
database/db-sqlx-sqlite/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

View File

@ -0,0 +1,22 @@
[package]
name = "db-sqlx-sqlite"
version = "0.1.0"
edition = "2021"
homepage = "https://github.com/realaravinth/gists"
repository = "https://github.com/realaravinth/gists"
documentation = "https://github.con/realaravinth/gists"
readme = "https://github.com/realaravinth/gists/blob/master/README.md"
license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
include = ["./mgrations/"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
sqlx = { version = "0.5.10", features = [ "sqlite", "time", "offline" ] }
db-core = {path = "../db-core"}
async-trait = "0.1.51"
[dev-dependencies]
actix-rt = "2"
sqlx = { version = "0.5.10", features = [ "runtime-actix-rustls", "postgres", "time", "offline" ] }
db-core = {path = "../db-core", features = ["test"]}

View File

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS admin_users (
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) UNIQUE DEFAULT NULL,
email_verified BOOLEAN DEFAULT NULL,
secret varchar(50) NOT NULL UNIQUE,
password VARCHAR(150) NOT NULL,
ID INTEGER PRIMARY KEY NOT NULL
);

View File

@ -0,0 +1,169 @@
{
"db": "SQLite",
"08e90ce06e795bdb9bf0ea5b18df3b69b107764ad9d224de601b3588fbeac211": {
"query": "UPDATE admin_users set secret = $1\n WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
}
},
"09381773667052a357cc51fec91a4004e09ba5ce7ebce2b7378ff637a5cb89a1": {
"query": "SELECT id from admin_users WHERE email = $1",
"describe": {
"columns": [
{
"name": "ID",
"ordinal": 0,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
}
},
"150c8d182ca90bd50fdd419e5b1b2bb48c8eb5d060d7ab0207dfc04e8eda6fee": {
"query": "INSERT INTO admin_users \n (username , password, secret) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
}
},
"1c6e0ac5913665e512c5d6c8b99af61e6adbac6482de651ff8aeab4210ba4120": {
"query": "UPDATE admin_users set email = $1\n WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
}
},
"29f35f75a0ddedaabdfecd6a22ee16747f91b2b928641361fe43738eb21e7607": {
"query": "DELETE FROM admin_users WHERE username = ($1)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
}
},
"2f9e7d2c4e5335a1c221f8162f5c47c44efbea37cc41670873303f6892e62375": {
"query": "UPDATE admin_users set password = $1\n WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
}
},
"3e91a474c261bae1ca27f1a5fcf9a276b5fa000039f5118b6daf5640b8708894": {
"query": "SELECT secret FROM admin_users WHERE username = ($1)",
"describe": {
"columns": [
{
"name": "secret",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
}
},
"4899a9845675082afcfca09d016da313f09932882f5e71ca7da26db94aa14dc1": {
"query": "UPDATE admin_users set username = $1 WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
}
},
"524065157b7c8e0a2ff02eb8efd7ddf79f585b2f1428f9d2bab92b3d2b2b9b71": {
"query": "insert into admin_users \n (username , password, email, secret) values ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
}
},
"677c618882d6b7e621f9f2b040671d8260518383796efad120a8ffdb596e9d7a": {
"query": "SELECT username, password FROM admin_users WHERE email = ($1)",
"describe": {
"columns": [
{
"name": "username",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
}
},
"c020b4503f7869c5f32cce9a871c99a19e5000bc9aa03ebb0ce8176397367cd3": {
"query": "SELECT id from admin_users WHERE username = $1",
"describe": {
"columns": [
{
"name": "ID",
"ordinal": 0,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
}
},
"dadfd47b8d33b0b636a79716afa5c8afdf9873304ff9fe883de7eae3aa5e8504": {
"query": "SELECT password FROM admin_users WHERE username = ($1)",
"describe": {
"columns": [
{
"name": "password",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
}
}
}

View File

@ -0,0 +1,25 @@
use std::borrow::Cow;
use db_core::dev::*;
use sqlx::Error;
pub fn map_register_err(e: Error) -> DBError {
if let Error::Database(err) = e {
if err.code() == Some(Cow::from("2067")) {
let msg = err.message();
if msg.contains("admin_users.username") {
DBError::DuplicateUsername
} else if msg.contains("admin_users.email") {
DBError::DuplicateEmail
} else if msg.contains("admin_users.secret") {
DBError::DuplicateSecret
} else {
DBError::DBError(Box::new(Error::Database(err)))
}
} else {
DBError::DBError(Box::new(Error::Database(err)))
}
} else {
DBError::DBError(Box::new(e))
}
}

View File

@ -0,0 +1,247 @@
use db_core::dev::*;
use sqlx::sqlite::SqlitePool;
use sqlx::sqlite::SqlitePoolOptions;
pub mod errors;
#[cfg(test)]
pub mod tests;
pub struct Database {
pub pool: SqlitePool,
}
/// Use an existing database pool
pub struct Conn(pub SqlitePool);
/// Connect to databse
pub enum ConnectionOptions {
/// fresh connection
Fresh(Fresh),
/// existing connection
Existing(Conn),
}
pub struct Fresh {
pub pool_options: SqlitePoolOptions,
pub url: String,
}
pub mod dev {
pub use super::errors::*;
pub use super::Database;
pub use db_core::dev::*;
pub use prelude::*;
pub use sqlx::Error;
}
pub mod prelude {
pub use super::*;
pub use db_core::prelude::*;
}
#[async_trait]
impl Connect for ConnectionOptions {
type Pool = Database;
async fn connect(self) -> DBResult<Self::Pool> {
let pool = match self {
Self::Fresh(fresh) => fresh
.pool_options
.connect(&fresh.url)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?,
Self::Existing(conn) => conn.0,
};
Ok(Database { pool })
}
}
use dev::*;
#[async_trait]
impl Migrate for Database {
async fn migrate(&self) -> DBResult<()> {
sqlx::migrate!("./migrations/")
.run(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
Ok(())
}
}
#[async_trait]
impl GistDatabase for Database {
async fn email_login(&self, email: &str) -> DBResult<Creds> {
sqlx::query_as!(
Creds,
r#"SELECT username, password FROM admin_users WHERE email = ($1)"#,
email,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
Error::RowNotFound => DBError::AccountNotFound,
e => DBError::DBError(Box::new(e)),
})
}
async fn username_login(&self, username: &str) -> DBResult<Password> {
sqlx::query_as!(
Password,
r#"SELECT password FROM admin_users WHERE username = ($1)"#,
username,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
Error::RowNotFound => DBError::AccountNotFound,
e => DBError::DBError(Box::new(e)),
})
}
async fn email_register(&self, payload: &EmailRegisterPayload) -> DBResult<()> {
sqlx::query!(
"insert into admin_users
(username , password, email, secret) values ($1, $2, $3, $4)",
payload.username,
payload.password,
payload.email,
payload.secret,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
Ok(())
}
async fn username_register(&self, payload: &UsernameRegisterPayload) -> DBResult<()> {
sqlx::query!(
"INSERT INTO admin_users
(username , password, secret) VALUES ($1, $2, $3)",
payload.username,
payload.password,
payload.secret,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
Ok(())
}
async fn update_email(&self, payload: &UpdateEmailPayload) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set email = $1
WHERE username = $2",
payload.email,
payload.username,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn update_password(&self, payload: &Creds) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set password = $1
WHERE username = $2",
payload.password,
payload.username,
)
.execute(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn email_exists(&self, email: &str) -> DBResult<bool> {
let exists;
match sqlx::query!("SELECT id from admin_users WHERE email = $1", email)
.fetch_one(&self.pool)
.await
{
Ok(_) => exists = true,
Err(Error::RowNotFound) => exists = false,
Err(e) => return Err(DBError::DBError(Box::new(e))),
};
Ok(exists)
}
async fn delete_account(&self, username: &str) -> DBResult<()> {
sqlx::query!("DELETE FROM admin_users WHERE username = ($1)", username,)
.execute(&self.pool)
.await
.map_err(|e| DBError::DBError(Box::new(e)))?;
Ok(())
}
async fn username_exists(&self, username: &str) -> DBResult<bool> {
let exists;
match sqlx::query!("SELECT id from admin_users WHERE username = $1", username)
.fetch_one(&self.pool)
.await
{
Ok(_) => exists = true,
Err(Error::RowNotFound) => exists = false,
Err(e) => return Err(DBError::DBError(Box::new(e))),
};
Ok(exists)
}
async fn update_username(&self, payload: &UpdateUsernamePayload) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set username = $1 WHERE username = $2",
payload.new_username,
payload.old_username,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn update_secret(&self, username: &str, secret: &str) -> DBResult<()> {
let x = sqlx::query!(
"UPDATE admin_users set secret = $1
WHERE username = $2",
secret,
username,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
if x.rows_affected() == 0 {
return Err(DBError::AccountNotFound);
}
Ok(())
}
async fn get_secret(&self, username: &str) -> DBResult<String> {
struct Secret {
secret: String,
}
let secret = sqlx::query_as!(
Secret,
r#"SELECT secret FROM admin_users WHERE username = ($1)"#,
username,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
Error::RowNotFound => DBError::AccountNotFound,
e => DBError::DBError(Box::new(e)),
})?;
Ok(secret.secret)
}
}

View File

@ -0,0 +1,40 @@
use sqlx::sqlite::SqlitePoolOptions;
use std::env;
use crate::*;
use db_core::tests::*;
#[actix_rt::test]
async fn everyting_works() {
const EMAIL: &str = "sqliteuser@foo.com";
const EMAIL2: &str = "sqliteuse2r@foo.com";
const NAME: &str = "sqliteuser";
const NAME2: &str = "sqliteuser2";
const NAME3: &str = "sqliteuser3";
const NAME4: &str = "sqliteuser4";
const NAME5: &str = "sqliteuser5";
const NAME6: &str = "sqliteuser6";
const NAME7: &str = "sqliteuser7";
const PASSWORD: &str = "pasdfasdfasdfadf";
const SECRET1: &str = "sqlitesecret1";
const SECRET2: &str = "sqlitesecret2";
const SECRET3: &str = "sqlitesecret3";
const SECRET4: &str = "sqlitesecret4";
let url = env::var("SQLITE_DATABASE_URL").expect("Set SQLITE_DATABASE_URL env var");
let pool_options = SqlitePoolOptions::new().max_connections(2);
let connection_options = ConnectionOptions::Fresh(Fresh { pool_options, url });
let db = connection_options.connect().await.unwrap();
db.migrate().await.unwrap();
email_register_works(&db, EMAIL, NAME, PASSWORD, SECRET1, NAME5).await;
username_register_works(&db, NAME2, PASSWORD, SECRET2).await;
duplicate_secret_guard_works(&db, NAME3, PASSWORD, NAME4, SECRET3, SECRET2).await;
duplicate_username_and_email(&db, NAME6, NAME7, EMAIL2, PASSWORD, SECRET4, NAME, EMAIL).await;
let creds = Creds {
username: NAME.into(),
password: SECRET4.into(),
};
db.update_password(&creds).await.unwrap();
}

2
database/migrator/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

1423
database/migrator/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
[package]
name = "migrator"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace]
[dependencies]
sqlx = { version = "0.5.10", features = [ "runtime-actix-rustls", "sqlite", "postgres", "time", "offline" ] }
actix-rt = "2"

View File

@ -0,0 +1,63 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::env;
use sqlx::migrate::MigrateDatabase;
use sqlx::postgres::PgPoolOptions;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Sqlite;
#[cfg(not(tarpaulin_include))]
#[actix_rt::main]
async fn main() {
//TODO featuregate sqlite and postgres
postgres_migrate().await;
sqlite_migrate().await;
}
async fn postgres_migrate() {
let db_url = env::var("POSTGRES_DATABASE_URL").expect("set POSTGRES_DATABASE_URL env var");
let db = PgPoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
.expect("Unable to form database pool");
sqlx::migrate!("../db-sqlx-postgres/migrations/")
.run(&db)
.await
.unwrap();
}
async fn sqlite_migrate() {
let db_url = env::var("SQLITE_DATABASE_URL").expect("Set SQLITE_DATABASE_URL env var");
if !matches!(Sqlite::database_exists(&db_url).await, Ok(true)) {
Sqlite::create_database(&db_url).await.unwrap();
}
let db = SqlitePoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
.expect("Unable to form database pool");
sqlx::migrate!("../db-sqlx-sqlite/migrations/")
.run(&db)
.await
.unwrap();
}