Finish initial setup

This commit is contained in:
Kristofers Solo 2025-01-25 19:42:36 +02:00
parent 8b08f428a2
commit 4d1b5b5376
32 changed files with 434 additions and 258 deletions

49
Cargo.lock generated
View File

@ -97,23 +97,44 @@ checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
name = "app"
version = "0.1.0"
dependencies = [
"argon2",
"cfg-if",
"chrono",
"config 0.15.6",
"hex",
"http",
"leptos",
"leptos_axum",
"leptos_meta",
"leptos_router",
"password-hash",
"rand",
"secrecy",
"serde",
"serde-aux",
"sqlx",
"thiserror 2.0.11",
"tokio",
"tracing",
"tracing-bunyan-formatter",
"tracing-log 0.2.0",
"tracing-subscriber",
"unicode-segmentation",
"uuid",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-compression"
version = "0.4.18"
@ -289,6 +310,15 @@ dependencies = [
"serde",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -1892,6 +1922,17 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -2419,21 +2460,13 @@ version = "0.1.0"
dependencies = [
"app",
"axum",
"config 0.15.6",
"leptos",
"leptos_axum",
"rand",
"secrecy",
"serde",
"serde-aux",
"sqlx",
"thiserror 2.0.11",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-log 0.2.0",
"unicode-segmentation",
"uuid",
]

View File

@ -48,6 +48,9 @@ config = { version = "0.15", features = ["toml"], default-features = false }
serde-aux = "4"
unicode-segmentation = "1"
rand = "0.8"
argon2 = "0.5"
password-hash = "0.5"
hex = "0.4"
# See https://github.com/leptos-rs/cargo-leptos for documentation of all the parameters.
@ -55,7 +58,7 @@ rand = "0.8"
# that are used together frontend (lib) & server (bin)
[[workspace.metadata.leptos]]
# this name is used for the wasm, js and css file names
name = "start-axum-workspace"
name = "echoes-of-ascension"
# the package in the workspace that contains the server binary (binary crate)
bin-package = "server"

View File

@ -21,8 +21,17 @@ tracing-bunyan-formatter.workspace = true
tracing-log.workspace = true
sqlx.workspace = true
uuid.workspace = true
tokio.workspace = true
chrono.workspace = true
serde.workspace = true
secrecy.workspace = true
unicode-segmentation.workspace = true
rand.workspace = true
config.workspace = true
serde-aux.workspace = true
argon2.workspace = true
password-hash.workspace = true
hex.workspace = true
[features]
default = []

1
app/src/db/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod users;

27
app/src/db/users.rs Normal file
View File

@ -0,0 +1,27 @@
use sqlx::PgPool;
use crate::models::user::{error::UserError, new_user::NewUser};
#[tracing::instrument(name = "Saving new user details in the database", skip(new_user, pool))]
pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), UserError> {
sqlx::query!(
r#"
INSERT INTO "user" (username, code)
VALUES ($1, $2)
"#,
new_user.username.as_ref(),
new_user.code.hash()?
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
match e {
sqlx::Error::Database(ref dbe) if dbe.constraint() == Some("user_username_key") => {
UserError::UsernameTaken(new_user.username.as_ref().to_string())
}
_ => UserError::Database(e),
}
})?;
Ok(())
}

View File

@ -1,4 +1,8 @@
pub mod config;
pub mod db;
pub mod models;
pub mod server_fn;
pub mod startup;
pub mod telemetry;
use leptos::prelude::*;
@ -32,7 +36,7 @@ pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/start-axum-workspace.css" />
<Stylesheet id="leptos" href="/pkg/echoes-of-ascension.css" />
// sets the document title
<Title text="Welcome to Leptos" />

View File

@ -1,2 +1,2 @@
pub mod response;
pub mod user;

View File

@ -0,0 +1,35 @@
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use super::user::new_user::NewUser;
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterResponse {
pub username: String,
pub code: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
}
impl<T> From<T> for ErrorResponse
where
T: Into<String>,
{
fn from(value: T) -> Self {
Self {
error: value.into(),
}
}
}
impl From<NewUser> for RegisterResponse {
fn from(value: NewUser) -> Self {
Self {
username: value.username.as_ref().to_string(),
code: value.code.expose_secret().to_string(),
}
}
}

View File

@ -1,21 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub username: String,
pub code: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserRegistration {
pub username: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserLogin {
pub code: String,
}

View File

@ -0,0 +1,25 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UserError {
#[error("Username validation failed: {0}")]
UsernameValidation(String),
#[error("Code hashing failed: {0}")]
HashingError(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Username already taken: {0}")]
UsernameTaken(String),
#[error("Invalid code format")]
InvalidCode,
#[error("Authentication failed")]
AuthenticationFailed,
#[error("Internal server error: {0}")]
Internal(String),
}

View File

@ -1,3 +1,4 @@
pub mod error;
pub mod new_user;
pub mod user_code;
pub mod username;

View File

@ -0,0 +1,25 @@
use serde::{Deserialize, Serialize};
use super::{user_code::UserCode, username::Username};
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterUserForm {
pub username: String,
}
#[derive(Debug, Default)]
pub struct NewUser {
pub username: Username,
pub code: UserCode,
}
impl TryFrom<RegisterUserForm> for NewUser {
type Error = String;
fn try_from(value: RegisterUserForm) -> Result<Self, Self::Error> {
let username = value.username.try_into()?;
Ok(Self {
username,
..Default::default()
})
}
}

View File

@ -0,0 +1,73 @@
use argon2::Argon2;
use password_hash::SaltString;
use std::ops::Deref;
use rand::{rngs::OsRng, thread_rng, Rng};
use secrecy::{ExposeSecret, SecretString};
use super::error::UserError;
#[derive(Debug)]
pub struct UserCode(SecretString);
impl UserCode {
pub fn hash(&self) -> Result<String, UserError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let mut output_key_material = [0u8; 32];
argon2
.hash_password_into(
self.expose_secret().as_bytes(),
salt.as_str().as_bytes(),
&mut output_key_material,
)
.map_err(|e| UserError::HashingError(e.to_string()))?;
Ok(format!(
"{}${}",
salt.as_str(),
hex::encode(output_key_material)
))
}
pub fn verify(stored: &str, code: &str) -> Result<bool, UserError> {
let argon2 = Argon2::default();
// Split stored value into salt and hash
let parts: Vec<&str> = stored.split('$').collect();
if parts.len() != 2 {
return Err(UserError::HashingError("Invalid hash format".to_string()));
}
let salt = parts[0];
let stored_hash =
hex::decode(parts[1]).map_err(|e| UserError::HashingError(e.to_string()))?;
let mut output = [0u8; 32];
argon2
.hash_password_into(code.as_bytes(), salt.as_bytes(), &mut output)
.map_err(|e| UserError::HashingError(e.to_string()))?;
Ok(output.as_slice() == stored_hash.as_slice())
}
}
impl Default for UserCode {
fn default() -> Self {
let mut rng = thread_rng();
let code = (0..16)
.map(|_| rng.gen_range(0..10).to_string())
.collect::<String>();
Self(code.into())
}
}
impl Deref for UserCode {
type Target = SecretString;
fn deref(&self) -> &Self::Target {
&self.0
}
}

28
app/src/server_fn/auth.rs Normal file
View File

@ -0,0 +1,28 @@
use crate::db::users::insert_user;
use crate::models::user::error::UserError;
use crate::models::user::new_user::RegisterUserForm;
use crate::{models::response::RegisterResponse, startup::AppState};
use leptos::{prelude::*, server};
#[server(RegisterUser, "/api/v1/users")]
pub async fn register_user(username: String) -> Result<RegisterResponse, ServerFnError<String>> {
let state = use_context::<AppState>()
.ok_or_else(|| ServerFnError::ServerError("AppState not found".into()))?;
let form = RegisterUserForm { username };
let new_user = form.try_into().map_err(|e| ServerFnError::ServerError(e))?;
match insert_user(&state.pool, &new_user).await {
Ok(_) => Ok(RegisterResponse::from(new_user)),
Err(UserError::UsernameTaken(username)) => Err(ServerFnError::ServerError(format!(
"Username {} is already taken",
username
))),
Err(e) => {
tracing::error!("Failed to register user: {}", e);
Err(ServerFnError::ServerError(
"Internal server error".to_string(),
))
}
}
}

1
app/src/server_fn/mod.rs Normal file
View File

@ -0,0 +1 @@
mod auth;

34
app/src/startup.rs Normal file
View File

@ -0,0 +1,34 @@
use std::sync::Arc;
use leptos::config::{errors::LeptosConfigError, LeptosOptions};
use sqlx::{postgres::PgPoolOptions, PgPool};
use thiserror::Error;
use crate::config::DatabaseSettings;
pub type AppState = Arc<App>;
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Leptos configuration error: {0}")]
LeptosConfig(#[from] LeptosConfigError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Server error: {0}")]
Server(String),
}
#[derive(Debug)]
pub struct App {
pub pool: PgPool,
pub leptos_options: LeptosOptions,
}
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}

View File

@ -1,7 +1,5 @@
set dotenv-load
export RUSTC_WRAPPER:="sccache"
# List all available commands
default:
@just --list

View File

@ -13,15 +13,7 @@ leptos_axum.workspace = true
axum.workspace = true
tokio.workspace = true
tower.workspace = true
thiserror.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-log.workspace = true
sqlx.workspace = true
serde.workspace = true
serde-aux.workspace = true
config.workspace = true
secrecy.workspace = true
uuid.workspace = true
unicode-segmentation.workspace = true
rand.workspace = true

37
server/src/application.rs Normal file
View File

@ -0,0 +1,37 @@
use app::{
config::Settings,
startup::{get_connection_pool, App, ApplicationError},
};
use leptos::prelude::*;
use tokio::{net::TcpListener, task::JoinHandle};
use crate::routes::route;
#[derive(Debug)]
pub struct Server(JoinHandle<Result<(), std::io::Error>>);
impl Server {
pub async fn build(config: Settings) -> Result<Self, ApplicationError> {
let pool = get_connection_pool(&config.database);
// Get Leptos configuration but override the address
let conf = get_configuration(None)?;
// Use application's address configuration
let addr = conf.leptos_options.site_addr;
let listener = TcpListener::bind(addr).await?;
let app_state = App {
pool,
leptos_options: conf.leptos_options,
}
.into();
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
Ok(Self(server))
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.0.await?
}
}

View File

@ -1,7 +0,0 @@
use super::{user_code::UserCode, username::Username};
#[derive(Debug, Default)]
pub struct NewUser {
pub username: Username,
pub code: UserCode,
}

View File

@ -1,33 +0,0 @@
use std::ops::Deref;
use rand::{thread_rng, Rng};
use secrecy::SecretString;
#[derive(Debug)]
pub struct UserCode(SecretString);
impl UserCode {
pub fn generate() -> Self {
Self::default()
}
}
impl Default for UserCode {
fn default() -> Self {
let mut rng = thread_rng();
let code = (0..16)
.map(|_| rng.gen_range(0..10).to_string())
.collect::<String>();
Self(code.into())
}
}
impl Deref for UserCode {
type Target = SecretString;
fn deref(&self) -> &Self::Target {
&self.0
}
}

View File

@ -1,4 +0,0 @@
pub mod config;
pub mod domain;
pub mod routes;
pub mod startup;

View File

@ -1,17 +1,23 @@
use app::telemetry::{get_subscriber, init_subscriber};
mod application;
mod routes;
use app::{
config::get_config,
startup::ApplicationError,
telemetry::{get_subscriber, init_subscriber},
};
use application::Server;
use leptos::prelude::*;
use server::config::get_config;
use server::startup::{Application, ApplicationError};
#[tokio::main]
async fn main() -> Result<(), ApplicationError> {
// Generate the list of routes in your Leptos App
let subscriber = get_subscriber("echoes-of-ascension-backend", "info", std::io::stdout);
let subscriber = get_subscriber("echoes-of-ascension-server", "info", std::io::stdout);
init_subscriber(subscriber);
let config = get_config().expect("Failed to read configuation.");
let application = Application::build(config).await?;
let application = Server::build(config).await?;
application.run_until_stopped().await?;
Ok(())
}

View File

@ -0,0 +1,8 @@
mod v1;
use app::startup::AppState;
use axum::Router;
pub fn routes() -> Router<AppState> {
Router::new().nest("/v1", v1::routes())
}

View File

@ -0,0 +1,41 @@
use app::db::users::insert_user;
use app::models::{
response::{ErrorResponse, RegisterResponse},
user::{error::UserError, new_user::RegisterUserForm},
};
use app::startup::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
#[tracing::instrument(
name = "Creating new user",
skip(data, state),
fields(
username= %data.username,
)
)]
pub async fn register(
State(state): State<AppState>,
Json(data): Json<RegisterUserForm>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let new_user = data
.try_into()
.map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse::from(e))))?;
match insert_user(&state.pool, &new_user).await {
Ok(_) => Ok((StatusCode::CREATED, Json(RegisterResponse::from(new_user)))),
Err(UserError::UsernameTaken(username)) => Err((
StatusCode::CONFLICT,
Json(ErrorResponse::from(format!(
"Username {} is already taken.",
username
))),
)),
Err(e) => {
tracing::error!("Failed to register user: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse::from("Internal server error")),
))
}
}
}

View File

@ -0,0 +1,8 @@
mod auth;
use app::startup::AppState;
use axum::{routing::post, Router};
pub fn routes() -> Router<AppState> {
Router::new().route("/register", post(auth::register))
}

View File

@ -1,30 +1,39 @@
mod api;
mod health_check;
mod user;
use std::time::Duration;
use app::{shell, App};
use app::{shell, startup::AppState, App};
use axum::{
body::Bytes,
extract::MatchedPath,
http::{HeaderMap, Request},
response::Response,
routing::{get, post},
routing::get,
Router,
};
use health_check::health_check;
use leptos_axum::{generate_route_list, LeptosRoutes};
use std::time::Duration;
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
use tracing::{info_span, Span};
use user::register;
use uuid::Uuid;
use crate::startup::AppState;
pub fn route(state: AppState) -> Router {
let leptos_options = state.leptos_options.clone();
let routes = generate_route_list(App);
Router::new()
.merge(leptos_routes(state.clone()))
.merge(api_routes(state))
.route("/health_check", get(health_check))
// API routes with proper nesting
.nest("/api", api::routes())
.with_state(state)
// Leptos setup
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options)
// Tracing layer
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
@ -51,27 +60,3 @@ pub fn route(state: AppState) -> Router {
),
)
}
fn leptos_routes(state: AppState) -> Router {
let leptos_options = state.leptos_options.clone();
let routes = generate_route_list(App);
Router::new()
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options)
}
fn api_routes(state: AppState) -> Router {
Router::new()
.nest(
"/api/v1",
Router::new()
.route("/health_check", get(health_check))
.route("/register", post(register)),
)
.with_state(state)
}

View File

@ -1,2 +0,0 @@
mod register;
pub use register::register;

View File

@ -1,56 +0,0 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Form};
use secrecy::ExposeSecret;
use serde::Deserialize;
use sqlx::PgPool;
use tracing::error;
use crate::{domain::new_user::NewUser, startup::AppState};
#[derive(Deserialize)]
pub struct FormData {
username: String,
}
pub async fn register(
State(state): State<AppState>,
Form(form): Form<FormData>,
) -> impl IntoResponse {
let new_user = match form.try_into() {
Ok(subscriber) => subscriber,
Err(_) => return StatusCode::BAD_REQUEST,
};
if insert_user(&state.pool, &new_user).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR;
}
todo!()
}
#[tracing::instrument(name = "Saving new user details in the database", skip(new_user, pool))]
pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO "user" (username, code)
VALUES ($1, $2)
"#,
new_user.username.as_ref(),
new_user.code.expose_secret()
)
.execute(pool)
.await
.map_err(|e| {
error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
impl TryFrom<FormData> for NewUser {
type Error = String;
fn try_from(value: FormData) -> Result<Self, Self::Error> {
let username = value.username.try_into()?;
Ok(Self {
username,
..Default::default()
})
}
}

View File

@ -1,75 +0,0 @@
use std::sync::Arc;
use leptos::config::{errors::LeptosConfigError, get_configuration, LeptosOptions};
use sqlx::{postgres::PgPoolOptions, PgPool};
use thiserror::Error;
use tokio::{net::TcpListener, task::JoinHandle};
use crate::{
config::{DatabaseSettings, Settings},
routes::route,
};
pub type AppState = Arc<App>;
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Leptos configuration error: {0}")]
LeptosConfig(#[from] LeptosConfigError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Server error: {0}")]
Server(String),
}
#[derive(Debug)]
pub struct App {
pub pool: PgPool,
pub leptos_options: LeptosOptions,
}
#[derive(Debug)]
pub struct Application {
port: u16,
server: JoinHandle<Result<(), std::io::Error>>,
}
impl Application {
pub async fn build(config: Settings) -> Result<Self, ApplicationError> {
let pool = get_connection_pool(&config.database);
// Get Leptos configuration but override the address
let conf = get_configuration(None)?;
// Use application's address configuration
let addr = conf.leptos_options.site_addr;
let listener = TcpListener::bind(addr).await?;
let port = listener.local_addr()?.port();
let app_state = App {
pool,
leptos_options: conf.leptos_options,
}
.into();
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
Ok(Self { port, server })
}
pub fn port(&self) -> u16 {
self.port
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.server.await?
}
}
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}