Compare commits
5 Commits
d18ce42938
...
54350e3919
| Author | SHA1 | Date |
|---|---|---|
|
|
54350e3919 | |
|
|
a2887ecc36 | |
|
|
5ad4f80fc1 | |
|
|
8663a271dc | |
|
|
c502f87fa3 |
|
|
@ -1,2 +1,2 @@
|
||||||
[env]
|
[env]
|
||||||
LOCALHUB_STATIC_FILE_PATH = { value = "static", relative = true }
|
LOCALITY_STATIC_FILE_PATH = { value = "static", relative = true }
|
||||||
|
|
|
||||||
13
Cargo.toml
13
Cargo.toml
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "localhub"
|
name = "locality"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -7,11 +7,12 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"]}
|
tokio = { version = "1", features = ["rt-multi-thread"]}
|
||||||
axum = "0.7"
|
axum = { version = "0.7", default_features = false, features = ["http1", "form", "tokio"] }
|
||||||
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"] }
|
||||||
r2d2_postgres = "0.18"
|
deadpool-postgres = { version = "0.12", features = ["rt_tokio_1"] }
|
||||||
deadpool-r2d2 = {version = "0.3", features = ["rt_tokio_1"]}
|
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
|
||||||
deadpool = "0.10"
|
thiserror = "1.0"
|
||||||
thiserror = "1.0"
|
argon2 = { version = "0.5", features = ["password-hash", "std"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
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::{extract::State, routing::get, Router},
|
axum::{routing::get, Router},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Router<Database> {
|
pub fn routes() -> Router<Database> {
|
||||||
|
|
@ -14,7 +14,6 @@ struct IndexTemplate<'a> {
|
||||||
title: &'a str,
|
title: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root<'a>(State(database): State<Database>) -> IndexTemplate<'a> {
|
async fn root<'a>() -> IndexTemplate<'a> {
|
||||||
println!("Found {} users", database.log_num_users().await);
|
IndexTemplate { title: "Locality" }
|
||||||
IndexTemplate { title: "LocalHub" }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
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 = "LOCALHUB_";
|
const ENV_VAR_PREFIX: &str = "LOCALITY_";
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
|
|
||||||
47
src/db.rs
47
src/db.rs
|
|
@ -1,47 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
//! 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
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,8 +1,11 @@
|
||||||
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;
|
||||||
|
|
@ -13,7 +16,7 @@ pub enum Error {
|
||||||
MissingConfigError(#[from] config::Error),
|
MissingConfigError(#[from] config::Error),
|
||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
DatabaseError(#[from] db::Error),
|
DatabaseError(#[from] db::InitialisationError),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
IOError(#[from] std::io::Error),
|
IOError(#[from] std::io::Error),
|
||||||
|
|
@ -21,7 +24,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(localhub_main()) {
|
std::process::exit(match runtime.block_on(locality_main()) {
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("ERROR: {}", err);
|
eprintln!("ERROR: {}", err);
|
||||||
|
|
@ -30,12 +33,15 @@ fn main() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn localhub_main() -> Result<(), Error> {
|
async fn locality_main() -> Result<(), Error> {
|
||||||
let config = get_config()?;
|
let config = get_config()?;
|
||||||
|
|
||||||
let db_pool = Database::create_pool(&config.database_url, 2)?;
|
let db_pool = Database::new(&config.database_url)?;
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% 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 %}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}First-time Setup - Locality Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
TODO: first_login.html
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Locality Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}admin{% endblock %}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!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,21 +1,3 @@
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en-US">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<title>{{title}}</title>
|
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
{% block content %}It still works!{% endblock %}
|
||||||
<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