Compare commits
1 Commits
main
...
1d707e9161
| Author | SHA1 | Date | |
|---|---|---|---|
|
1d707e9161
|
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,14 +7,3 @@
|
|||||||
/result
|
/result
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Anything the config crate looks for
|
|
||||||
config.ini
|
|
||||||
config.json
|
|
||||||
config.json5
|
|
||||||
config.ron
|
|
||||||
config.toml
|
|
||||||
config.yaml
|
|
||||||
config.yml
|
|
||||||
|
|
||||||
/migrations/.diesel_lock
|
|
||||||
|
|||||||
670
Cargo.lock
generated
670
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -9,22 +9,16 @@ edition = "2024"
|
|||||||
build = "src/build.rs"
|
build = "src/build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum-login = { version = "0.18.0", optional = true }
|
|
||||||
cfg-if = "1.0.4"
|
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
|
||||||
config = { version = "0.15.24", optional = true }
|
config = { version = "0.15.24", optional = true }
|
||||||
diesel = { version = "2.3.10", optional = true, features = ["chrono"] }
|
diesel = { version = "2.3.10", optional = true, features = [ "postgres" ] }
|
||||||
diesel-async = { version = "0.9.1", optional = true, features = ["postgres", "deadpool", "migrations"] }
|
diesel_migrations = { version = "2.3.2", optional = true, features = [ "postgres" ] }
|
||||||
diesel_migrations = { version = "2.3.2", optional = true }
|
|
||||||
dioxus = { version = "0.7.9", features = ["router", "fullstack"] }
|
dioxus = { version = "0.7.9", features = ["router", "fullstack"] }
|
||||||
dotenvy = { version = "0.15.7", optional = true }
|
dotenvy = { version = "0.15.7", optional = true }
|
||||||
fred = { version = "10.1.0", optional = true }
|
|
||||||
lucide-dioxus = { version = "3.11.0", features = ["notifications"] }
|
lucide-dioxus = { version = "3.11.0", features = ["notifications"] }
|
||||||
pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"] }
|
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tower-sessions-redis-store = { version = "0.16.0", optional = true }
|
tokio = { version = "1.52.3", optional = true, features = ["rt-multi-thread"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
@@ -32,15 +26,11 @@ default = ["web"]
|
|||||||
web = ["dioxus/web"]
|
web = ["dioxus/web"]
|
||||||
server = [
|
server = [
|
||||||
"dioxus/server",
|
"dioxus/server",
|
||||||
"dep:axum-login",
|
|
||||||
"dep:config",
|
"dep:config",
|
||||||
"dep:diesel",
|
"dep:diesel",
|
||||||
"dep:diesel-async",
|
|
||||||
"dep:diesel_migrations",
|
"dep:diesel_migrations",
|
||||||
"dep:dotenvy",
|
"dep:dotenvy",
|
||||||
"dep:fred",
|
"dep:tokio",
|
||||||
"dep:pbkdf2",
|
|
||||||
"dep:tower-sessions-redis-store",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Disabled until supported
|
# Disabled until supported
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP INDEX users_username_idx;
|
|
||||||
DROP TABLE users;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY UNIQUE NOT NULL GENERATED ALWAYS AS IDENTITY,
|
|
||||||
username VARCHAR UNIQUE NOT NULL,
|
|
||||||
hashed_password VARCHAR NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX users_username_idx ON users(username);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use crate::models::user::{User, UserCredentials};
|
|
||||||
use crate::util::error::Result;
|
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
|
||||||
if #[cfg(feature = "server")] {
|
|
||||||
|
|
||||||
use dioxus::server::axum::Extension;
|
|
||||||
|
|
||||||
use crate::server::{auth::{AuthSession, create_user}, config::Config, database::DbPool};
|
|
||||||
use crate::util::error::{AuthError, Contextualize, Error, ErrorType};
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/api/v1/auth/signup", mut auth: Extension<AuthSession>, db_pool: Extension<DbPool>, config: Extension<Config>)]
|
|
||||||
pub async fn signup(credentials: UserCredentials) -> Result<User> {
|
|
||||||
if !config.auth.open_signup {
|
|
||||||
return Err(Error::new_here(ErrorType::Auth(AuthError::Unauthorized)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow signup when already logged in
|
|
||||||
if auth.user.is_some() {
|
|
||||||
return Err(Error::new_here(ErrorType::Auth(AuthError::Unauthorized)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let hashed_creds = credentials
|
|
||||||
.try_hash()
|
|
||||||
.map_err(|e| Error::message_here(e.to_string()))
|
|
||||||
.err_context("Error hashing new user credentials")?;
|
|
||||||
|
|
||||||
let mut db_conn = db_pool
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.err_context("Failed to get database pool connection")?;
|
|
||||||
|
|
||||||
let new_user = create_user(&mut db_conn, &hashed_creds)
|
|
||||||
.await
|
|
||||||
.err_context("Error creating user")?;
|
|
||||||
|
|
||||||
// Don't return this to the client, logging in immediately isn't strictly necessary
|
|
||||||
if let Err(e) = auth.login(&new_user).await {
|
|
||||||
tracing::warn!("Failed to log in user after creating: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(new_user.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/api/v1/auth/login", mut auth: Extension<AuthSession>)]
|
|
||||||
pub async fn login(credentials: UserCredentials) -> Result<User> {
|
|
||||||
let db_user = match auth.authenticate(credentials).await {
|
|
||||||
Ok(Some(db_user)) => Ok(db_user),
|
|
||||||
Ok(None) => Err(Error::new_here(ErrorType::Auth(
|
|
||||||
AuthError::InvalidCredentials,
|
|
||||||
))),
|
|
||||||
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
|
|
||||||
AuthError::Error(format!("Session error: {e}")),
|
|
||||||
))),
|
|
||||||
Err(axum_login::Error::Backend(e)) => Err(e),
|
|
||||||
}
|
|
||||||
.err_context("Error authenticating")?;
|
|
||||||
|
|
||||||
auth.login(&db_user)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::new_here(ErrorType::Auth(AuthError::Error(e.to_string()))))
|
|
||||||
.err_context("Error logging in")?;
|
|
||||||
|
|
||||||
Ok(db_user.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/api/v1/auth/logout", mut auth: Extension<AuthSession>)]
|
|
||||||
pub async fn logout() -> Result<()> {
|
|
||||||
match auth.logout().await {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
|
|
||||||
AuthError::Error(format!("Session error: {e}")),
|
|
||||||
))),
|
|
||||||
Err(axum_login::Error::Backend(e)) => Err(e),
|
|
||||||
}
|
|
||||||
.err_context("Error logging out")
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
pub mod auth;
|
|
||||||
|
|||||||
12
src/main.rs
12
src/main.rs
@@ -3,10 +3,8 @@ pub mod app;
|
|||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
pub mod util;
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server;
|
pub mod server;
|
||||||
@@ -25,11 +23,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn main() -> std::process::ExitCode {
|
fn main() {
|
||||||
tracing_setup();
|
tracing_setup();
|
||||||
|
server::main()
|
||||||
let Err(e) = server::main();
|
|
||||||
tracing::error!("Server main failed:\n{e}");
|
|
||||||
|
|
||||||
std::process::ExitCode::FAILURE
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
pub mod user;
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
//! Various user types. Some types marked server-only to help prevent
|
|
||||||
//! leaking passwords to the frontend
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Standard informational user type, contains no password information
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[cfg_attr(feature = "server", derive(Queryable, Selectable, Identifiable))]
|
|
||||||
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
|
|
||||||
check_for_backend(diesel::pg::Pg)))]
|
|
||||||
pub struct User {
|
|
||||||
pub id: i32,
|
|
||||||
pub username: String,
|
|
||||||
pub created_at: chrono::DateTime<chrono::Local>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Plaintext user credentials, used for login/signup form
|
|
||||||
#[derive(Deserialize, Serialize)]
|
|
||||||
pub struct UserCredentials {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
|
||||||
if #[cfg(feature = "server")] {
|
|
||||||
|
|
||||||
use diesel::{
|
|
||||||
deserialize::{FromSql, FromSqlRow},
|
|
||||||
expression::AsExpression,
|
|
||||||
prelude::*,
|
|
||||||
serialize::ToSql,
|
|
||||||
sql_types,
|
|
||||||
};
|
|
||||||
use pbkdf2::{
|
|
||||||
PasswordHasher, PasswordVerifier, Pbkdf2, password_hash::Error::PasswordInvalid,
|
|
||||||
phc::PasswordHash,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::util::error::{Error, Result};
|
|
||||||
|
|
||||||
/// Newtype for a `String`-represented hashed password
|
|
||||||
#[derive(Clone, Debug, AsExpression, FromSqlRow)]
|
|
||||||
#[diesel(sql_type = sql_types::Text)]
|
|
||||||
pub struct HashedPassword(String);
|
|
||||||
|
|
||||||
impl HashedPassword {
|
|
||||||
/// Check a password attempt against this hashed password
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// `Ok(true)` for a correct password
|
|
||||||
/// `Ok(false)` for an incorrect password
|
|
||||||
/// `Err` for a hashing error
|
|
||||||
pub fn check(&self, password_attempt: String) -> Result<bool> {
|
|
||||||
let pw_hash = PasswordHash::new(&self.0)
|
|
||||||
.map_err(|e| Error::message_here(format!("Error parsing `HashedPassword`: {e}")))?;
|
|
||||||
|
|
||||||
match Pbkdf2::default().verify_password(password_attempt.as_bytes(), &pw_hash) {
|
|
||||||
Ok(()) => Ok(true),
|
|
||||||
Err(PasswordInvalid) => Ok(false),
|
|
||||||
Err(e) => Err(Error::message_here(format!(
|
|
||||||
"Error comparing password attempt against hash: {e}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the "session auth hash" for `axum-login`, just the hashed password as bytes
|
|
||||||
pub fn auth_hash(&self) -> &[u8] {
|
|
||||||
self.0.as_bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<DB> FromSql<diesel::sql_types::Text, DB> for HashedPassword
|
|
||||||
where
|
|
||||||
DB: diesel::backend::Backend,
|
|
||||||
String: FromSql<sql_types::Text, DB>,
|
|
||||||
{
|
|
||||||
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
|
||||||
Ok(Self(String::from_sql(bytes)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<DB> ToSql<diesel::sql_types::Text, DB> for HashedPassword
|
|
||||||
where
|
|
||||||
DB: diesel::backend::Backend,
|
|
||||||
String: ToSql<sql_types::Text, DB>,
|
|
||||||
{
|
|
||||||
fn to_sql<'b>(
|
|
||||||
&'b self,
|
|
||||||
out: &mut diesel::serialize::Output<'b, '_, DB>,
|
|
||||||
) -> diesel::serialize::Result {
|
|
||||||
self.0.to_sql(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User as it appears in the database, with hashed password
|
|
||||||
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
|
||||||
#[diesel(table_name = crate::schema::users, check_for_backend(diesel::pg::Pg))]
|
|
||||||
pub struct DbUser {
|
|
||||||
pub id: i32,
|
|
||||||
pub username: String,
|
|
||||||
pub hashed_password: HashedPassword,
|
|
||||||
pub created_at: chrono::DateTime<chrono::Local>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DbUser> for User {
|
|
||||||
fn from(db_user: DbUser) -> Self {
|
|
||||||
User {
|
|
||||||
id: db_user.id,
|
|
||||||
username: db_user.username,
|
|
||||||
created_at: db_user.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User credentials with hashed password
|
|
||||||
#[derive(Clone, Debug, Insertable, Queryable, Selectable)]
|
|
||||||
#[diesel(table_name = crate::schema::users, check_for_backend(diesel::pg::Pg))]
|
|
||||||
pub struct HashedUserCredentials {
|
|
||||||
username: String,
|
|
||||||
hashed_password: HashedPassword,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DbUser> for HashedUserCredentials {
|
|
||||||
fn from(db_user: DbUser) -> Self {
|
|
||||||
HashedUserCredentials {
|
|
||||||
username: db_user.username,
|
|
||||||
hashed_password: db_user.hashed_password,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserCredentials {
|
|
||||||
/// Attempt to convert into `HashedUserCredentials` by hashing the password. Yields a PBKDF2
|
|
||||||
/// error on failure.
|
|
||||||
pub fn try_hash(self) -> Result<HashedUserCredentials, pbkdf2::password_hash::Error> {
|
|
||||||
let hashed_password = Pbkdf2::default().hash_password(self.password.as_bytes())?;
|
|
||||||
|
|
||||||
Ok(HashedUserCredentials {
|
|
||||||
username: self.username,
|
|
||||||
hashed_password: HashedPassword(hashed_password.to_string()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1 @@
|
|||||||
// @generated automatically by Diesel CLI.
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
users (id) {
|
|
||||||
id -> Int4,
|
|
||||||
username -> Varchar,
|
|
||||||
hashed_password -> Varchar,
|
|
||||||
created_at -> Timestamptz,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use diesel_async::RunQueryDsl;
|
|
||||||
use tower_sessions_redis_store::RedisStore;
|
|
||||||
|
|
||||||
use crate::models::user::{DbUser, HashedUserCredentials, UserCredentials};
|
|
||||||
use crate::server::{
|
|
||||||
database::{DbConn, DbPool},
|
|
||||||
key_val_store::KeyValPool,
|
|
||||||
};
|
|
||||||
use crate::util::error::{Contextualize, Error, Result};
|
|
||||||
|
|
||||||
pub type AuthLayer = AuthManagerLayer<AuthBackend, RedisStore<KeyValPool>>;
|
|
||||||
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
|
|
||||||
|
|
||||||
impl AuthUser for DbUser {
|
|
||||||
type Id = i32;
|
|
||||||
|
|
||||||
fn id(&self) -> Self::Id {
|
|
||||||
self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_auth_hash(&self) -> &[u8] {
|
|
||||||
self.hashed_password.auth_hash()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AuthBackend {
|
|
||||||
pub db_pool: DbPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthnBackend for AuthBackend {
|
|
||||||
type User = DbUser;
|
|
||||||
type Credentials = UserCredentials;
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
async fn authenticate(
|
|
||||||
&self,
|
|
||||||
attempt_creds: Self::Credentials,
|
|
||||||
) -> Result<Option<Self::User>, Self::Error> {
|
|
||||||
let mut db_conn = self
|
|
||||||
.db_pool
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.err_context("Failed to get database pool connection")?;
|
|
||||||
|
|
||||||
let user = get_user_by_username(&mut db_conn, attempt_creds.username)
|
|
||||||
.await
|
|
||||||
.err_context("Error fetching user for authentication check")?;
|
|
||||||
|
|
||||||
let Some(user) = user else { return Ok(None) };
|
|
||||||
|
|
||||||
let password_result = user
|
|
||||||
.hashed_password
|
|
||||||
.check(attempt_creds.password)
|
|
||||||
.err_context("Error checking user password attempt")?;
|
|
||||||
|
|
||||||
if password_result {
|
|
||||||
Ok(Some(user))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
|
||||||
let mut db_conn = self
|
|
||||||
.db_pool
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.err_context("Failed to get database pool connection")?;
|
|
||||||
|
|
||||||
get_user_by_id(&mut db_conn, *user_id)
|
|
||||||
.await
|
|
||||||
.err_context("Failed fetching user for session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_user(
|
|
||||||
db_conn: &mut DbConn,
|
|
||||||
credentials: &HashedUserCredentials,
|
|
||||||
) -> Result<DbUser> {
|
|
||||||
diesel::insert_into(crate::schema::users::table)
|
|
||||||
.values(credentials)
|
|
||||||
.get_result(db_conn)
|
|
||||||
.await
|
|
||||||
.err_context("Error creating user")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> {
|
|
||||||
crate::schema::users::table
|
|
||||||
.find(id)
|
|
||||||
.first(db_conn)
|
|
||||||
.await
|
|
||||||
.optional()
|
|
||||||
.err_context("Error fetching user from database by id")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_by_username(
|
|
||||||
db_conn: &mut DbConn,
|
|
||||||
username: String,
|
|
||||||
) -> Result<Option<DbUser>> {
|
|
||||||
crate::schema::users::table
|
|
||||||
.filter(crate::schema::users::username.eq(username))
|
|
||||||
.first(db_conn)
|
|
||||||
.await
|
|
||||||
.optional()
|
|
||||||
.err_context("Error fetching user from database by username")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the authentication middleware layer
|
|
||||||
pub fn build_auth_layer(db_pool: DbPool, key_val_pool: KeyValPool) -> AuthLayer {
|
|
||||||
use axum_login::{AuthManagerLayerBuilder, tower_sessions::SessionManagerLayer};
|
|
||||||
use tower_sessions_redis_store::RedisStore;
|
|
||||||
|
|
||||||
let auth_session_store = RedisStore::new(key_val_pool);
|
|
||||||
let session_layer = SessionManagerLayer::new(auth_session_store);
|
|
||||||
|
|
||||||
let auth_backend = AuthBackend { db_pool };
|
|
||||||
|
|
||||||
AuthManagerLayerBuilder::new(auth_backend, session_layer).build()
|
|
||||||
}
|
|
||||||
@@ -1,148 +1,9 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct AuthConfig {
|
|
||||||
pub open_signup: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a connection URI from parts
|
|
||||||
fn format_uri(
|
|
||||||
scheme: &str,
|
|
||||||
username: &Option<String>,
|
|
||||||
password: &Option<String>,
|
|
||||||
host: &str,
|
|
||||||
port: &Option<u16>,
|
|
||||||
path: &Option<String>,
|
|
||||||
) -> String {
|
|
||||||
let mut url = format!("{scheme}://");
|
|
||||||
|
|
||||||
if let Some(username) = username {
|
|
||||||
url.push_str(username);
|
|
||||||
|
|
||||||
if let Some(password) = password {
|
|
||||||
url.push_str(&format!(":{password}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
url.push('@');
|
|
||||||
}
|
|
||||||
|
|
||||||
url.push_str(host);
|
|
||||||
|
|
||||||
if let Some(port) = port {
|
|
||||||
url.push_str(&format!(":{port}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path) = path {
|
|
||||||
url.push_str(&format!("/{path}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
url
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct DatabaseConfig {
|
|
||||||
#[serde(flatten)]
|
|
||||||
connection: DatabaseConnectionConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabaseConfig {
|
|
||||||
/// Get the configured database connection URI
|
|
||||||
pub fn connection_uri(&self) -> String {
|
|
||||||
self.connection.as_uri()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum DatabaseConnectionConfig {
|
|
||||||
FromUrl {
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
FromParts {
|
|
||||||
host: String,
|
|
||||||
port: Option<u16>,
|
|
||||||
database: Option<String>,
|
|
||||||
username: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabaseConnectionConfig {
|
|
||||||
/// Convert this configuration into the Postgres connection URI
|
|
||||||
pub fn as_uri(&self) -> String {
|
|
||||||
match self {
|
|
||||||
Self::FromUrl { url } => url.clone(),
|
|
||||||
Self::FromParts {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
database,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
} => format_uri("postgres", username, password, host, port, database),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum KeyValStoreConnectionConfig {
|
|
||||||
FromUrl {
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
FromParts {
|
|
||||||
scheme: Option<String>,
|
|
||||||
host: String,
|
|
||||||
port: Option<u16>,
|
|
||||||
database: Option<String>,
|
|
||||||
username: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyValStoreConnectionConfig {
|
|
||||||
/// Convert this configuration into the Redis connection URI
|
|
||||||
pub fn as_uri(&self) -> String {
|
|
||||||
match self {
|
|
||||||
Self::FromUrl { url } => url.clone(),
|
|
||||||
Self::FromParts {
|
|
||||||
scheme,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
database,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
} => format_uri(
|
|
||||||
scheme.as_deref().unwrap_or("redis"),
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
database,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct KeyValStoreConfig {
|
|
||||||
#[serde(flatten)]
|
|
||||||
connection: KeyValStoreConnectionConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyValStoreConfig {
|
|
||||||
/// Get the configured database connection URI
|
|
||||||
pub fn connection_uri(&self) -> String {
|
|
||||||
self.connection.as_uri()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
/// Top-level application configuration
|
/// Top-level application configuration
|
||||||
pub struct Config {
|
pub struct Config {}
|
||||||
pub auth: AuthConfig,
|
|
||||||
pub database: DatabaseConfig,
|
|
||||||
pub key_val_store: KeyValStoreConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse configuration from the expected files and environment variables
|
/// Parse configuration from the expected files and environment variables
|
||||||
pub fn load_config() -> Result<Config, config::ConfigError> {
|
pub fn load_config() -> Result<Config, config::ConfigError> {
|
||||||
@@ -152,7 +13,6 @@ pub fn load_config() -> Result<Config, config::ConfigError> {
|
|||||||
|
|
||||||
config::Config::builder()
|
config::Config::builder()
|
||||||
.set_default("server.port", 8080)?
|
.set_default("server.port", 8080)?
|
||||||
.set_default("auth.open_signup", false)?
|
|
||||||
.add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false))
|
.add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false))
|
||||||
.add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false))
|
.add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false))
|
||||||
.add_source(File::with_name("config").required(false))
|
.add_source(File::with_name("config").required(false))
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
use diesel_async::{
|
|
||||||
AsyncMigrationHarness, AsyncPgConnection,
|
|
||||||
pooled_connection::{AsyncDieselConnectionManager, deadpool::Pool},
|
|
||||||
};
|
|
||||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
|
||||||
|
|
||||||
use crate::util::error::{Contextualize, Error, ErrorType};
|
|
||||||
|
|
||||||
pub const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
|
||||||
|
|
||||||
pub type DbPool = Pool<AsyncPgConnection>;
|
|
||||||
pub type DbConn = AsyncPgConnection;
|
|
||||||
|
|
||||||
/// Connect to the database using the given URI, and perform migrations
|
|
||||||
pub async fn setup<S: Into<String>>(database_uri: S) -> Result<DbPool, Error> {
|
|
||||||
let pool_manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_uri);
|
|
||||||
|
|
||||||
let pool = Pool::builder(pool_manager)
|
|
||||||
.build()
|
|
||||||
// At time of writing only the `NoRuntimeSpecified` error is possible from the builder,
|
|
||||||
// which should only occur when configuring timeouts without a `Runtime`
|
|
||||||
.map_err(|e| ErrorType::Database(e.to_string()))
|
|
||||||
.err_context("Error creating pool for database connections")?;
|
|
||||||
|
|
||||||
tracing::debug!("Establishing connection to database for migrations...");
|
|
||||||
|
|
||||||
let migration_conn = pool
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.err_context("Failed to get connection to database")?;
|
|
||||||
|
|
||||||
tracing::debug!("Running migrations...");
|
|
||||||
|
|
||||||
AsyncMigrationHarness::new(migration_conn)
|
|
||||||
.run_pending_migrations(DB_MIGRATIONS)
|
|
||||||
.map_err(|e| ErrorType::Database(e.to_string()))
|
|
||||||
.err_context("Failed to run pending database migrations")?;
|
|
||||||
|
|
||||||
Ok(pool)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use fred::prelude::*;
|
|
||||||
|
|
||||||
use crate::util::error::{Contextualize, Error, ErrorType};
|
|
||||||
|
|
||||||
const KEY_VAL_POOL_SIZE: usize = 4;
|
|
||||||
|
|
||||||
pub type KeyValPool = Pool;
|
|
||||||
|
|
||||||
pub async fn setup(connection_uri: &str) -> Result<KeyValPool, Error> {
|
|
||||||
let config = Config::from_url(connection_uri)
|
|
||||||
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
|
|
||||||
.err_context("Error creating key-value store config")?;
|
|
||||||
|
|
||||||
let pool = Builder::from_config(config)
|
|
||||||
.build_pool(KEY_VAL_POOL_SIZE)
|
|
||||||
// At time of writing the only error that could occur here is if config is not provided.
|
|
||||||
// Since we're building a pool `from_config`, this shouldn't be possible
|
|
||||||
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
|
|
||||||
.err_context("Error creating pool for key-value store")?;
|
|
||||||
|
|
||||||
tracing::debug!("Establishing connection to key-value store...");
|
|
||||||
|
|
||||||
pool.init()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
|
|
||||||
.err_context("Error connecting to key-value store")?;
|
|
||||||
|
|
||||||
Ok(pool)
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,9 @@
|
|||||||
use dioxus::{fullstack::axum::Router, server::axum::Extension};
|
|
||||||
|
|
||||||
use crate::App;
|
use crate::App;
|
||||||
use crate::server::{auth::build_auth_layer, config, database, key_val_store};
|
|
||||||
use crate::util::error::{Contextualize, Error, Result};
|
|
||||||
|
|
||||||
pub fn main() -> Result<std::convert::Infallible> {
|
pub fn main() {
|
||||||
if let Err(e) = dotenvy::dotenv() {
|
if let Err(e) = dotenvy::dotenv() {
|
||||||
tracing::warn!("Error reading .env: {e}");
|
tracing::warn!("Error reading .env: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// `Ok(...?)` is because `dioxus::serve` expects an `anyhow::Result`
|
dioxus::launch(App);
|
||||||
dioxus::serve(async move || Ok(router_setup().await?));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up the axum Router
|
|
||||||
async fn router_setup() -> Result<Router> {
|
|
||||||
tracing::debug!("Loading configuration...");
|
|
||||||
let config = config::load_config()
|
|
||||||
.map_err(|e| Error::message_here(e.to_string()))
|
|
||||||
.err_context("Failed to load config")?;
|
|
||||||
|
|
||||||
let db_pool = database::setup(config.database.connection_uri())
|
|
||||||
.await
|
|
||||||
.err_context("Failed database setup")?;
|
|
||||||
|
|
||||||
let key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
|
|
||||||
.await
|
|
||||||
.err_context("Failed key-value store setup")?;
|
|
||||||
|
|
||||||
let auth_layer = build_auth_layer(db_pool.clone(), key_val_pool);
|
|
||||||
|
|
||||||
let router = dioxus::server::router(App)
|
|
||||||
.layer(Extension(config))
|
|
||||||
.layer(Extension(db_pool))
|
|
||||||
.layer(auth_layer);
|
|
||||||
|
|
||||||
tracing::info!("Setup complete, returning Router...");
|
|
||||||
Ok(router)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
pub mod auth;
|
|
||||||
pub mod config;
|
|
||||||
pub mod database;
|
|
||||||
pub mod key_val_store;
|
|
||||||
pub mod main;
|
pub mod main;
|
||||||
|
pub mod config;
|
||||||
|
|
||||||
pub use main::main;
|
pub use main::main;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ impl fmt::Display for ErrorLocation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)]
|
#[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)]
|
||||||
pub struct Error {
|
pub struct Error {
|
||||||
@@ -74,12 +74,6 @@ impl Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new basic `Error` at this location with the given message
|
|
||||||
#[track_caller]
|
|
||||||
pub fn message_here<S: Into<String>>(message: S) -> Self {
|
|
||||||
Error::new(ErrorType::Error(message.into()), ErrorLocation::here())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a context message to the error
|
/// Adds a context message to the error
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
pub fn with_context(mut self, context: impl Into<String>) -> Self {
|
pub fn with_context(mut self, context: impl Into<String>) -> Self {
|
||||||
@@ -239,14 +233,12 @@ impl From<ServerFnError> for Error {
|
|||||||
impl dioxus_fullstack::AsStatusCode for Error {
|
impl dioxus_fullstack::AsStatusCode for Error {
|
||||||
fn as_status_code(&self) -> StatusCode {
|
fn as_status_code(&self) -> StatusCode {
|
||||||
match &self.source {
|
match &self.source {
|
||||||
ErrorType::Auth(AuthError::InvalidCredentials | AuthError::Unauthorized) => {
|
|
||||||
StatusCode::UNAUTHORIZED
|
|
||||||
}
|
|
||||||
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
|
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
|
ErrorType::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
ErrorType::Error(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ErrorType::ServerFnError(e) => e.as_status_code(),
|
ErrorType::ServerFnError(e) => e.as_status_code(),
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,9 +277,6 @@ impl<T, E: Into<Error>> Contextualize<Result<T>> for E {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
|
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
|
||||||
pub enum ErrorType {
|
pub enum ErrorType {
|
||||||
#[error("Authentication error: {0}")]
|
|
||||||
Auth(AuthError),
|
|
||||||
|
|
||||||
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
|
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
|
||||||
// and Diesel is only available on the server
|
// and Diesel is only available on the server
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
@@ -298,11 +287,6 @@ pub enum ErrorType {
|
|||||||
|
|
||||||
#[error("Server function error: {0}")]
|
#[error("Server function error: {0}")]
|
||||||
ServerFnError(ServerFnError),
|
ServerFnError(ServerFnError),
|
||||||
|
|
||||||
// Using string to represent Fred errors, because Fred's Error type is not `Serialize`,
|
|
||||||
// and Fred is only available on the server
|
|
||||||
#[error("Key-value store error: {0}")]
|
|
||||||
KeyValStore(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ErrorType> for Error {
|
impl From<ErrorType> for Error {
|
||||||
@@ -319,33 +303,3 @@ impl From<diesel::result::Error> for Error {
|
|||||||
Error::new_here(ErrorType::Database(format!("{err}")))
|
Error::new_here(ErrorType::Database(format!("{err}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This would capture any `deapool::PoolError` and treat it as a database error
|
|
||||||
// but we're only using `deadpool` for our database, so it's fine
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
impl From<diesel_async::pooled_connection::deadpool::PoolError> for Error {
|
|
||||||
#[track_caller]
|
|
||||||
fn from(err: diesel_async::pooled_connection::deadpool::PoolError) -> Self {
|
|
||||||
Error::new_here(ErrorType::Database(format!("{err}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
impl From<fred::error::Error> for Error {
|
|
||||||
#[track_caller]
|
|
||||||
fn from(err: fred::error::Error) -> Self {
|
|
||||||
Error::new_here(ErrorType::KeyValStore(format!("{err}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
|
|
||||||
pub enum AuthError {
|
|
||||||
#[error("Invalid credentials")]
|
|
||||||
InvalidCredentials,
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Error(String),
|
|
||||||
|
|
||||||
#[error("Unauthorized")]
|
|
||||||
Unauthorized,
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user