Compare commits

..

No commits in common. "54350e3919e63d0c2e924e73603e50967e89c86e" and "d18ce42938eb995c8b73a79770aae5fbccdc69a1" have entirely different histories.

16 changed files with 83 additions and 671 deletions

View File

@ -1,2 +1,2 @@
[env]
LOCALITY_STATIC_FILE_PATH = { value = "static", relative = true }
LOCALHUB_STATIC_FILE_PATH = { value = "static", relative = true }

View File

@ -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"] }

View File

@ -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(
&params.real_name,
&params.email,
&Password::new(&params.password)?.into(),
)
.await?;
if let Some(_user) = authenticate_user(&db, user, &params.password).await? {
// Store cookie and display configuration page
todo!();
} else {
// Report failure
todo!();
}
Ok(FirstLoginTemplate {})
}

View File

@ -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" }
}

View File

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

View File

@ -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 {

47
src/db.rs Normal file
View File

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

View File

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

View File

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

View File

@ -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!()
}
}
}
}

View File

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

View File

@ -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 %}

View File

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block title %}First-time Setup - Locality Administration{% endblock %}
{% block content %}
TODO: first_login.html
{% endblock %}

View File

@ -1,5 +0,0 @@
{% extends "base.html" %}
{% block title %}Locality Administration{% endblock %}
{% block content %}admin{% endblock %}

View File

@ -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>

View File

@ -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>