Compare commits
No commits in common. "54350e3919e63d0c2e924e73603e50967e89c86e" and "d18ce42938eb995c8b73a79770aae5fbccdc69a1" have entirely different histories.
54350e3919
...
d18ce42938
|
|
@ -1,2 +1,2 @@
|
|||
[env]
|
||||
LOCALITY_STATIC_FILE_PATH = { value = "static", relative = true }
|
||||
LOCALHUB_STATIC_FILE_PATH = { value = "static", relative = true }
|
||||
|
|
|
|||
11
Cargo.toml
11
Cargo.toml
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "locality"
|
||||
name = "localhub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
|
@ -7,12 +7,11 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
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_axum = "0.4"
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
deadpool-postgres = { version = "0.12", features = ["rt_tokio_1"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
|
||||
r2d2_postgres = "0.18"
|
||||
deadpool-r2d2 = {version = "0.3", features = ["rt_tokio_1"]}
|
||||
deadpool = "0.10"
|
||||
thiserror = "1.0"
|
||||
argon2 = { version = "0.5", features = ["password-hash", "std"] }
|
||||
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 {
|
||||
crate::db::Database,
|
||||
askama::Template,
|
||||
axum::{routing::get, Router},
|
||||
axum::{extract::State, routing::get, Router},
|
||||
};
|
||||
|
||||
pub fn routes() -> Router<Database> {
|
||||
|
|
@ -14,6 +14,7 @@ struct IndexTemplate<'a> {
|
|||
title: &'a str,
|
||||
}
|
||||
|
||||
async fn root<'a>() -> IndexTemplate<'a> {
|
||||
IndexTemplate { title: "Locality" }
|
||||
async fn root<'a>(State(database): State<Database>) -> IndexTemplate<'a> {
|
||||
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};
|
||||
|
||||
const ENV_VAR_PREFIX: &str = "LOCALITY_";
|
||||
const ENV_VAR_PREFIX: &str = "LOCALHUB_";
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
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};
|
||||
|
||||
mod admin;
|
||||
mod authentication;
|
||||
mod app;
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
|
||||
use config::get_config;
|
||||
use db::Database;
|
||||
|
|
@ -16,7 +13,7 @@ pub enum Error {
|
|||
MissingConfigError(#[from] config::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] db::InitialisationError),
|
||||
DatabaseError(#[from] db::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
|
@ -24,7 +21,7 @@ pub enum Error {
|
|||
|
||||
fn main() {
|
||||
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,
|
||||
Err(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 db_pool = Database::new(&config.database_url)?;
|
||||
|
||||
db_pool.migrate_to_current_version().await.unwrap();
|
||||
let db_pool = Database::create_pool(&config.database_url, 2)?;
|
||||
|
||||
let app = app::routes()
|
||||
.nest("/admin", admin::routes())
|
||||
.with_state(db_pool)
|
||||
.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