Compare commits
No commits in common. "54350e3919e63d0c2e924e73603e50967e89c86e" and "d18ce42938eb995c8b73a79770aae5fbccdc69a1" have entirely different histories.
54350e3919
...
d18ce42938
|
|
@ -1,2 +1,2 @@
|
||||||
[env]
|
[env]
|
||||||
LOCALITY_STATIC_FILE_PATH = { value = "static", relative = true }
|
LOCALHUB_STATIC_FILE_PATH = { value = "static", relative = true }
|
||||||
|
|
|
||||||
13
Cargo.toml
13
Cargo.toml
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "locality"
|
name = "localhub"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -7,12 +7,11 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"]}
|
tokio = { version = "1", features = ["rt-multi-thread"]}
|
||||||
axum = { version = "0.7", default_features = false, features = ["http1", "form", "tokio"] }
|
axum = "0.7"
|
||||||
askama = "0.12"
|
askama = "0.12"
|
||||||
askama_axum = "0.4"
|
askama_axum = "0.4"
|
||||||
tower-http = { version = "0.5", features = ["fs"] }
|
tower-http = { version = "0.5", features = ["fs"] }
|
||||||
deadpool-postgres = { version = "0.12", features = ["rt_tokio_1"] }
|
r2d2_postgres = "0.18"
|
||||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
|
deadpool-r2d2 = {version = "0.3", features = ["rt_tokio_1"]}
|
||||||
thiserror = "1.0"
|
deadpool = "0.10"
|
||||||
argon2 = { version = "0.5", features = ["password-hash", "std"] }
|
thiserror = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
69
src/admin.rs
69
src/admin.rs
|
|
@ -1,69 +0,0 @@
|
||||||
use {
|
|
||||||
crate::{
|
|
||||||
authentication::{authenticate_user, Password},
|
|
||||||
db::Database,
|
|
||||||
error::Error,
|
|
||||||
},
|
|
||||||
askama::Template,
|
|
||||||
askama_axum::{IntoResponse, Response},
|
|
||||||
axum::{
|
|
||||||
extract::State,
|
|
||||||
routing::{get, post},
|
|
||||||
Form, Router,
|
|
||||||
},
|
|
||||||
serde::Deserialize,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn routes() -> Router<Database> {
|
|
||||||
Router::new()
|
|
||||||
.route("/", get(root))
|
|
||||||
.route("/create_initial_admin_user", post(create_first_admin_user))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "admin/create_first_user.html")]
|
|
||||||
struct CreateFirstUserTemplate {}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "admin/first_login.html")]
|
|
||||||
struct FirstLoginTemplate {}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "admin/index.html")]
|
|
||||||
struct IndexTemplate {}
|
|
||||||
|
|
||||||
async fn root(State(db): State<Database>) -> Result<Response, Error> {
|
|
||||||
Ok(if !db.has_admin_users().await? {
|
|
||||||
CreateFirstUserTemplate {}.into_response()
|
|
||||||
} else {
|
|
||||||
IndexTemplate {}.into_response()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CreateFirstUserParameters {
|
|
||||||
real_name: String,
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_first_admin_user(
|
|
||||||
State(db): State<Database>,
|
|
||||||
Form(params): Form<CreateFirstUserParameters>,
|
|
||||||
) -> Result<FirstLoginTemplate, Error> {
|
|
||||||
let user = db
|
|
||||||
.create_first_admin_user(
|
|
||||||
¶ms.real_name,
|
|
||||||
¶ms.email,
|
|
||||||
&Password::new(¶ms.password)?.into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(_user) = authenticate_user(&db, user, ¶ms.password).await? {
|
|
||||||
// Store cookie and display configuration page
|
|
||||||
todo!();
|
|
||||||
} else {
|
|
||||||
// Report failure
|
|
||||||
todo!();
|
|
||||||
}
|
|
||||||
Ok(FirstLoginTemplate {})
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use {
|
use {
|
||||||
crate::db::Database,
|
crate::db::Database,
|
||||||
askama::Template,
|
askama::Template,
|
||||||
axum::{routing::get, Router},
|
axum::{extract::State, routing::get, Router},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Router<Database> {
|
pub fn routes() -> Router<Database> {
|
||||||
|
|
@ -14,6 +14,7 @@ struct IndexTemplate<'a> {
|
||||||
title: &'a str,
|
title: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root<'a>() -> IndexTemplate<'a> {
|
async fn root<'a>(State(database): State<Database>) -> IndexTemplate<'a> {
|
||||||
IndexTemplate { title: "Locality" }
|
println!("Found {} users", database.log_num_users().await);
|
||||||
|
IndexTemplate { title: "LocalHub" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
use {
|
|
||||||
crate::{db, db::Database},
|
|
||||||
argon2::{
|
|
||||||
password_hash::{
|
|
||||||
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
|
||||||
},
|
|
||||||
Argon2,
|
|
||||||
},
|
|
||||||
std::ops::Deref,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum AuthenticationError {
|
|
||||||
#[error("Could not get password hash from database: {}", .0.to_string())]
|
|
||||||
DatabaseError(#[from] db::Error),
|
|
||||||
|
|
||||||
#[error("{}", .0.to_string())]
|
|
||||||
HashError(#[from] argon2::password_hash::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AuthenticatedUser {
|
|
||||||
user: db::User,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for AuthenticatedUser {
|
|
||||||
type Target = db::User;
|
|
||||||
|
|
||||||
fn deref(&self) -> &db::User {
|
|
||||||
&self.user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AuthenticatedAdminUser {
|
|
||||||
user: db::User,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for AuthenticatedAdminUser {
|
|
||||||
type Target = db::User;
|
|
||||||
|
|
||||||
fn deref(&self) -> &db::User {
|
|
||||||
&self.user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Password {
|
|
||||||
hash: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Password {
|
|
||||||
pub fn new(password: &str) -> Result<Password, AuthenticationError> {
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let argon2 = Argon2::default();
|
|
||||||
Ok(Password {
|
|
||||||
hash: argon2
|
|
||||||
.hash_password(password.as_bytes(), &salt)?
|
|
||||||
.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check(&self, password: &str) -> Result<bool, AuthenticationError> {
|
|
||||||
let hash = PasswordHash::new(&self.hash)?;
|
|
||||||
Ok(Argon2::default()
|
|
||||||
.verify_password(password.as_bytes(), &hash)
|
|
||||||
.is_ok())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<db::PasswordHash> for Password {
|
|
||||||
fn from(password: db::PasswordHash) -> Self {
|
|
||||||
Password {
|
|
||||||
hash: password.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Password> for db::PasswordHash {
|
|
||||||
fn from(password: Password) -> Self {
|
|
||||||
db::PasswordHash(password.hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn authenticate_user(
|
|
||||||
db: &Database,
|
|
||||||
user: db::User,
|
|
||||||
supplied_password: &str,
|
|
||||||
) -> Result<Option<AuthenticatedUser>, AuthenticationError> {
|
|
||||||
let password: Password = db.get_password_for_user(&user).await?.into();
|
|
||||||
Ok(if password.check(supplied_password)? {
|
|
||||||
Some(AuthenticatedUser { user })
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use {std::env, thiserror::Error};
|
use {std::env, thiserror::Error};
|
||||||
|
|
||||||
const ENV_VAR_PREFIX: &str = "LOCALITY_";
|
const ENV_VAR_PREFIX: &str = "LOCALHUB_";
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
use {
|
||||||
|
deadpool, deadpool_r2d2::Runtime, thiserror::Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
type PgManager = deadpool_r2d2::Manager<
|
||||||
|
r2d2_postgres::PostgresConnectionManager<r2d2_postgres::postgres::NoTls>,
|
||||||
|
>;
|
||||||
|
type PgPool = deadpool_r2d2::Pool<PgManager>;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Could not initialize DB connection pool.")]
|
||||||
|
ConnectionPoolError(#[from] deadpool::managed::BuildError),
|
||||||
|
#[error("Error with Postgres database")]
|
||||||
|
PostrgesError(#[from] r2d2_postgres::postgres::Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Database {
|
||||||
|
pg_pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn create_pool(connection_url: &str, max_size: usize) -> Result<Database, Error> {
|
||||||
|
let pg_config: r2d2_postgres::postgres::Config = connection_url.parse()?;
|
||||||
|
let r2d2_manager = r2d2_postgres::PostgresConnectionManager::new(
|
||||||
|
pg_config,
|
||||||
|
r2d2_postgres::postgres::NoTls,
|
||||||
|
);
|
||||||
|
let manager = PgManager::new(r2d2_manager, Runtime::Tokio1);
|
||||||
|
let pg_pool = PgPool::builder(manager).max_size(max_size).build()?;
|
||||||
|
Ok(Database { pg_pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn log_num_users(&self) -> usize {
|
||||||
|
self.pg_pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.interact(|conn| {
|
||||||
|
let results = conn.query("SELECT * FROM users;", &[]).unwrap();
|
||||||
|
results.len()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
//! Database schema migrations and the code to manage them.
|
|
||||||
//!
|
|
||||||
//! - [MIGRATIONS] stores a list of schema migrations defined in SQL.
|
|
||||||
//!
|
|
||||||
//! - [CURRENT_VERSION] is the schema version used by the current app
|
|
||||||
//! version.
|
|
||||||
//!
|
|
||||||
//! - [get_db_version()] returns the schema version of the database
|
|
||||||
//! itself.
|
|
||||||
|
|
||||||
//! - [migrate_to_current_version()] Applies whatever migrations are
|
|
||||||
//! needed to that the database schema version (as returned by
|
|
||||||
//! [get_db_version()]) matches [CURRENT_VERSION].
|
|
||||||
|
|
||||||
use super::{Database, Error};
|
|
||||||
|
|
||||||
/// Defines a database schema migration
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Migration {
|
|
||||||
/// The schema version that `up` will migrate to.
|
|
||||||
version: i32,
|
|
||||||
/// SQL to migrate to [version](Migration::version) from the
|
|
||||||
/// previous schema version.
|
|
||||||
///
|
|
||||||
/// May contain multiple SQL statements: they will be wrapped in a
|
|
||||||
/// `DO $$BEGIN`...`END$$;` block when executed.
|
|
||||||
up: &'static str,
|
|
||||||
/// SQL to migrate from [version](Migration::version) to the
|
|
||||||
/// previous schema version.
|
|
||||||
///
|
|
||||||
/// May contain multiple SQL statements: they will be wrapped in a
|
|
||||||
/// `DO $$BEGIN`...`END$$;` block when executed.
|
|
||||||
down: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A list of all the schema migrations.
|
|
||||||
///
|
|
||||||
/// The [version](Migration::version) field should always correspond to the
|
|
||||||
/// [Migration]'s position in the list, starting at 0 and working up
|
|
||||||
/// sequentially.
|
|
||||||
///
|
|
||||||
/// New versions should normally only ever add to the end of this
|
|
||||||
/// list, never change or add things in the middle or at the
|
|
||||||
/// beginning.
|
|
||||||
static MIGRATIONS: &[Migration] = &[
|
|
||||||
Migration {
|
|
||||||
version: 0,
|
|
||||||
up: r#"
|
|
||||||
CREATE TABLE users (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
real_name TEXT NOT NULL,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
-- 43 characters is enought to base64-encode 32 bits
|
|
||||||
password_salt CHARACTER(43) NOT NULL,
|
|
||||||
password_hash CHARACTER(43) NOT NULL);"#,
|
|
||||||
down: r#"DROP TABLE users;"#,
|
|
||||||
},
|
|
||||||
Migration {
|
|
||||||
version: 1,
|
|
||||||
up: r#"
|
|
||||||
CREATE TABLE admin_users (
|
|
||||||
id INTEGER PRIMARY KEY REFERENCES users);"#,
|
|
||||||
down: r#"
|
|
||||||
DROP TABLE admin_users;"#,
|
|
||||||
},
|
|
||||||
Migration {
|
|
||||||
// The original password_salt and password_hash fields were
|
|
||||||
// never used, and when I implemented passwords I ended up
|
|
||||||
// storing both in a single row.
|
|
||||||
version: 2,
|
|
||||||
up: r#"
|
|
||||||
ALTER TABLE users DROP COLUMN IF EXISTS password_salt;
|
|
||||||
ALTER TABLE users DROP COLUMN IF EXISTS password_hash;
|
|
||||||
ALTER TABLE users ADD COLUMN password TEXT NOT NULL;"#,
|
|
||||||
down: "ALTER TABLE users DROP COLUMN password;"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The current schema version. Normally this will be the
|
|
||||||
/// [version](Migration::version) of the list item in [MIGRATIONS].
|
|
||||||
///
|
|
||||||
/// This is the the current version *as specified by the
|
|
||||||
/// application*. It may not be the actual schema of the database. If
|
|
||||||
/// it is not the current database schema, then running
|
|
||||||
/// [migrate_to_current_version()] will migrate the database to this
|
|
||||||
/// version.
|
|
||||||
static CURRENT_VERSION: i32 = 2;
|
|
||||||
|
|
||||||
/// If the database is not already using the most recent schema, apply
|
|
||||||
/// one migration to bring it to the next newest version.
|
|
||||||
///
|
|
||||||
/// E.g. If [CURRENT_VERSION] is 10 but the database is on version 4,
|
|
||||||
/// then running this function will apply [Migration] 5 to bring the
|
|
||||||
/// database up to schema version 5.
|
|
||||||
async fn migrate_up(db: &Database) -> Result<i32, Error> {
|
|
||||||
let current_version = get_db_version(db).await?;
|
|
||||||
if let Some(migration) = MIGRATIONS.iter().find(|m| m.version > current_version) {
|
|
||||||
let client = db.connection_pool.get().await?;
|
|
||||||
client
|
|
||||||
.execute(&format!("DO $$BEGIN\n{}\nEND$$;", migration.up), &[])
|
|
||||||
.await?;
|
|
||||||
client
|
|
||||||
.execute(
|
|
||||||
"UPDATE migration_info SET migration_version = $1;",
|
|
||||||
&[&migration.version],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(migration.version)
|
|
||||||
} else {
|
|
||||||
eprintln!("ERROR: Attempted to migrate up past last migration.");
|
|
||||||
Ok(current_version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revert back to the previous schema version by running the
|
|
||||||
/// [down](Migration::down) SQL of the current `Migration`
|
|
||||||
async fn migrate_down(db: &Database) -> Result<i32, Error> {
|
|
||||||
let current_version = get_db_version(db).await?;
|
|
||||||
let mut migration_iter = MIGRATIONS
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.skip_while(|m| m.version != current_version);
|
|
||||||
if let Some(migration) = migration_iter.next() {
|
|
||||||
let client = db.connection_pool.get().await?;
|
|
||||||
client
|
|
||||||
.execute(&format!("DO $$BEGIN\n{}\nEND$$;", migration.down), &[])
|
|
||||||
.await?;
|
|
||||||
let version = migration_iter.next().map_or(-1, |m| m.version);
|
|
||||||
client
|
|
||||||
.execute(
|
|
||||||
"UPDATE migration_info SET migration_version = $1;",
|
|
||||||
&[&version],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(version)
|
|
||||||
} else {
|
|
||||||
eprintln!("ERROR: Attempted to migrate down past first migration.");
|
|
||||||
Ok(current_version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply whatever migrations are necessary to bring the database
|
|
||||||
/// schema to the same version is [CURRENT_VERSION].
|
|
||||||
pub async fn migrate_to_current_version(db: &Database) -> Result<(), Error> {
|
|
||||||
migrate_to_version(db, CURRENT_VERSION).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply whatever migrations are necessary to bring the database
|
|
||||||
/// schema to the same version as `target_version`.
|
|
||||||
///
|
|
||||||
/// This may migrate up or down as required.
|
|
||||||
async fn migrate_to_version(db: &Database, target_version: i32) -> Result<(), Error> {
|
|
||||||
let mut version = get_db_version(db).await?;
|
|
||||||
while version != target_version {
|
|
||||||
if version < target_version {
|
|
||||||
version = migrate_up(db).await?;
|
|
||||||
} else {
|
|
||||||
version = migrate_down(db).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current schema version of the database.
|
|
||||||
pub async fn get_db_version(db: &Database) -> Result<i32, Error> {
|
|
||||||
let client = db.connection_pool.get().await?;
|
|
||||||
client
|
|
||||||
.execute(
|
|
||||||
r#"
|
|
||||||
DO $$BEGIN
|
|
||||||
IF NOT EXISTS
|
|
||||||
( SELECT 1
|
|
||||||
FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'migration_info'
|
|
||||||
)
|
|
||||||
THEN
|
|
||||||
CREATE TABLE migration_info (
|
|
||||||
onerow_id BOOL PRIMARY KEY DEFAULT true,
|
|
||||||
migration_version INTEGER,
|
|
||||||
CONSTRAINT onerow_unique CHECK (onerow_id));
|
|
||||||
INSERT
|
|
||||||
INTO migration_info (migration_version)
|
|
||||||
VALUES (-1);
|
|
||||||
END IF;
|
|
||||||
END$$;"#,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(client
|
|
||||||
.query_one(r#"SELECT migration_version FROM migration_info;"#, &[])
|
|
||||||
.await?
|
|
||||||
.get(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::super::Database;
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
async fn test_db() -> Database {
|
|
||||||
let url = std::env::var("LOCALITY_TEST_DATABASE_URL").unwrap();
|
|
||||||
Database::new(&url)
|
|
||||||
.unwrap()
|
|
||||||
.connection_pool
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.execute(
|
|
||||||
r#"
|
|
||||||
DO $$BEGIN
|
|
||||||
DROP TABlE IF EXISTS admin_users;
|
|
||||||
DROP TABlE IF EXISTS users;
|
|
||||||
DROP TABLE IF EXISTS migration_info;
|
|
||||||
END$$;"#,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
Database::new(&url).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migrations_have_sequential_versions() {
|
|
||||||
for i in 0..MIGRATIONS.len() {
|
|
||||||
assert_eq!(i as i32, MIGRATIONS[i].version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn migrate_up_and_down_all() {
|
|
||||||
let db = test_db().await;
|
|
||||||
assert_eq!(-1, get_db_version(&db).await.unwrap());
|
|
||||||
migrate_to_version(&db, MIGRATIONS.last().unwrap().version)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
MIGRATIONS.last().unwrap().version,
|
|
||||||
get_db_version(&db).await.unwrap()
|
|
||||||
);
|
|
||||||
migrate_to_version(&db, -1).await.unwrap();
|
|
||||||
assert_eq!(-1, get_db_version(&db).await.unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
144
src/db/mod.rs
144
src/db/mod.rs
|
|
@ -1,144 +0,0 @@
|
||||||
//! Code for managing the main database.
|
|
||||||
//!
|
|
||||||
//! The database schema is defined via a series of migrations in
|
|
||||||
//! [migrations].
|
|
||||||
|
|
||||||
mod migrations;
|
|
||||||
|
|
||||||
use {
|
|
||||||
deadpool_postgres::{CreatePoolError, Pool, Runtime},
|
|
||||||
tokio_postgres::NoTls,
|
|
||||||
std::ops::Deref
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Errors that may occur during module initialization
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum InitialisationError {
|
|
||||||
#[error("Could not initialize DB connection pool: {}", .0.to_string())]
|
|
||||||
ConnectionPoolError(#[from] CreatePoolError),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors that may occur during normal app operation
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error("{}", .0.to_string())]
|
|
||||||
Pool(#[from] deadpool_postgres::PoolError),
|
|
||||||
|
|
||||||
#[error("{}", .0.to_string())]
|
|
||||||
Postgres(#[from] tokio_postgres::Error),
|
|
||||||
|
|
||||||
#[error("Not allowed.")]
|
|
||||||
NotAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Object that manages the database.
|
|
||||||
///
|
|
||||||
/// All database access happens through this struct.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct Database {
|
|
||||||
connection_pool: Pool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct UserId(i32);
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct User {
|
|
||||||
id: UserId,
|
|
||||||
pub real_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PasswordHash(pub String);
|
|
||||||
|
|
||||||
impl Deref for PasswordHash {
|
|
||||||
type Target = str;
|
|
||||||
|
|
||||||
fn deref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// Create a connection pool and return the [Database].
|
|
||||||
pub fn new(connection_url: &str) -> Result<Database, InitialisationError> {
|
|
||||||
let mut config = deadpool_postgres::Config::new();
|
|
||||||
config.url = Some(connection_url.to_string());
|
|
||||||
let pg_pool = config.create_pool(Some(Runtime::Tokio1), NoTls)?;
|
|
||||||
Ok(Database {
|
|
||||||
connection_pool: pg_pool,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run migrations as needed to ensure the database schema version
|
|
||||||
/// match the one used by the current version of the application.
|
|
||||||
pub async fn migrate_to_current_version(&self) -> Result<(), Error> {
|
|
||||||
migrations::migrate_to_current_version(self).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_client(&self) -> Result<deadpool_postgres::Client, Error> {
|
|
||||||
Ok(self.connection_pool.get().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn has_admin_users(&self) -> Result<bool, Error> {
|
|
||||||
let client = self.get_client().await?;
|
|
||||||
Ok(client
|
|
||||||
.query_one("SELECT EXISTS(SELECT 1 FROM admin_users);", &[])
|
|
||||||
.await?
|
|
||||||
.get(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_user(
|
|
||||||
&self,
|
|
||||||
real_name: &str,
|
|
||||||
email: &str,
|
|
||||||
password: &PasswordHash,
|
|
||||||
) -> Result<User, Error> {
|
|
||||||
let client = self.get_client().await?;
|
|
||||||
let id = client
|
|
||||||
.query_one(
|
|
||||||
r#"
|
|
||||||
INSERT INTO users
|
|
||||||
(real_name, email, password)
|
|
||||||
VALUES $1, $1, $3, $4
|
|
||||||
RETURNING id;"#,
|
|
||||||
&[&real_name, &email, &password.0],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.get(0);
|
|
||||||
Ok(User {
|
|
||||||
id: UserId(id),
|
|
||||||
real_name: real_name.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_password_for_user(&self, user: &User) -> Result<PasswordHash, Error> {
|
|
||||||
let client = self.get_client().await?;
|
|
||||||
let row = client
|
|
||||||
.query_one(
|
|
||||||
"SELECT password FROM users WHERE id = $1;",
|
|
||||||
&[&user.id.0],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(PasswordHash(row.get(0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_first_admin_user(
|
|
||||||
&self,
|
|
||||||
real_name: &str,
|
|
||||||
email: &str,
|
|
||||||
password: &PasswordHash,
|
|
||||||
) -> Result<User, Error> {
|
|
||||||
if self.has_admin_users().await? {
|
|
||||||
return Err(Error::NotAllowed)
|
|
||||||
}
|
|
||||||
let user = self.create_user(real_name, email, password).await?;
|
|
||||||
let client = self.get_client().await?;
|
|
||||||
client
|
|
||||||
.execute("INSERT INTO admin_users (id) VALUES $1", &[&user.id.0])
|
|
||||||
.await?;
|
|
||||||
Ok(User {
|
|
||||||
id: user.id,
|
|
||||||
real_name: user.real_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
src/error.rs
34
src/error.rs
|
|
@ -1,34 +0,0 @@
|
||||||
use {
|
|
||||||
crate::{authentication, db},
|
|
||||||
askama_axum::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error("Database Error: {}", 0.to_string())]
|
|
||||||
DatabaseError(#[from] db::Error),
|
|
||||||
|
|
||||||
#[error("Authentication error")]
|
|
||||||
AuthenticationError(#[from] authentication::AuthenticationError)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for Error {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
match self {
|
|
||||||
Error::DatabaseError(db::Error::Pool(pool_error)) => {
|
|
||||||
eprintln!("Database connection pool error: {}", pool_error);
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
Error::DatabaseError(db::Error::Postgres(postgres_error)) => {
|
|
||||||
eprintln!("Database error: {}", postgres_error);
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
Error::DatabaseError(db::Error::NotAllowed) => {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
Error::AuthenticationError(_) => {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
src/main.rs
14
src/main.rs
|
|
@ -1,11 +1,8 @@
|
||||||
use {thiserror::Error, tower_http::services::ServeDir};
|
use {thiserror::Error, tower_http::services::ServeDir};
|
||||||
|
|
||||||
mod admin;
|
|
||||||
mod authentication;
|
|
||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod error;
|
|
||||||
|
|
||||||
use config::get_config;
|
use config::get_config;
|
||||||
use db::Database;
|
use db::Database;
|
||||||
|
|
@ -16,7 +13,7 @@ pub enum Error {
|
||||||
MissingConfigError(#[from] config::Error),
|
MissingConfigError(#[from] config::Error),
|
||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
DatabaseError(#[from] db::InitialisationError),
|
DatabaseError(#[from] db::Error),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
IOError(#[from] std::io::Error),
|
IOError(#[from] std::io::Error),
|
||||||
|
|
@ -24,7 +21,7 @@ pub enum Error {
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
std::process::exit(match runtime.block_on(locality_main()) {
|
std::process::exit(match runtime.block_on(localhub_main()) {
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("ERROR: {}", err);
|
eprintln!("ERROR: {}", err);
|
||||||
|
|
@ -33,15 +30,12 @@ fn main() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn locality_main() -> Result<(), Error> {
|
async fn localhub_main() -> Result<(), Error> {
|
||||||
let config = get_config()?;
|
let config = get_config()?;
|
||||||
|
|
||||||
let db_pool = Database::new(&config.database_url)?;
|
let db_pool = Database::create_pool(&config.database_url, 2)?;
|
||||||
|
|
||||||
db_pool.migrate_to_current_version().await.unwrap();
|
|
||||||
|
|
||||||
let app = app::routes()
|
let app = app::routes()
|
||||||
.nest("/admin", admin::routes())
|
|
||||||
.with_state(db_pool)
|
.with_state(db_pool)
|
||||||
.nest_service("/static", ServeDir::new(&config.static_file_path));
|
.nest_service("/static", ServeDir::new(&config.static_file_path));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}First-time Setup - Locality Administration{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Please create an administrator account:</h1>
|
|
||||||
<form action="/create-initial-admin-user" method="post">>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<label for="real_name">Name:</label>
|
|
||||||
<input type="text" id="real_name" name="real_name" />
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label for="email">Email:</label>
|
|
||||||
<input type="email" id="email" name="email" />
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input type="password" id="password" name="password" />
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="submit">Create Account</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}First-time Setup - Locality Administration{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
TODO: first_login.html
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Locality Administration{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}admin{% endblock %}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en-US">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<title>{% block title %}{{title}} - Locality{% endblock %}</title>
|
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16.png">
|
|
||||||
<!-- For Google and Android -->
|
|
||||||
<link rel="icon" type="image/png" sizes="48x48" href="static/favicon-48.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="static/favicon-192.png">
|
|
||||||
<!-- For iPad -->
|
|
||||||
<link rel="apple-touch-icon" type="image/png" sizes="167x167" href="static/favicon-167.png">
|
|
||||||
<!-- For iPhone -->
|
|
||||||
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="static/favicon-180.png">
|
|
||||||
|
|
||||||
{% block head %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,3 +1,21 @@
|
||||||
{% extends "base.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<title>{{title}}</title>
|
||||||
|
|
||||||
{% block content %}It still works!{% endblock %}
|
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16.png">
|
||||||
|
<!-- For Google and Android -->
|
||||||
|
<link rel="icon" type="image/png" sizes="48x48" href="static/favicon-48.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="static/favicon-192.png">
|
||||||
|
<!-- For iPad -->
|
||||||
|
<link rel="apple-touch-icon" type="image/png" sizes="167x167" href="static/favicon-167.png">
|
||||||
|
<!-- For iPhone -->
|
||||||
|
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="static/favicon-180.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
It still works!
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue