diff --git a/Cargo.lock b/Cargo.lock index c8fa4fd..3701112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index cc15f37..85f2663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/app/Cargo.toml b/app/Cargo.toml index 4e06a8e..687a74f 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -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 = [] diff --git a/server/src/config.rs b/app/src/config.rs similarity index 100% rename from server/src/config.rs rename to app/src/config.rs diff --git a/app/src/db/mod.rs b/app/src/db/mod.rs new file mode 100644 index 0000000..913bd46 --- /dev/null +++ b/app/src/db/mod.rs @@ -0,0 +1 @@ +pub mod users; diff --git a/app/src/db/users.rs b/app/src/db/users.rs new file mode 100644 index 0000000..b997637 --- /dev/null +++ b/app/src/db/users.rs @@ -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(()) +} diff --git a/app/src/lib.rs b/app/src/lib.rs index 14327c1..649b365 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -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! { - + // sets the document title diff --git a/app/src/models/mod.rs b/app/src/models/mod.rs index 34b2ad4..601225e 100644 --- a/app/src/models/mod.rs +++ b/app/src/models/mod.rs @@ -1,2 +1,2 @@ +pub mod response; pub mod user; - diff --git a/app/src/models/response.rs b/app/src/models/response.rs new file mode 100644 index 0000000..90513eb --- /dev/null +++ b/app/src/models/response.rs @@ -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(), + } + } +} diff --git a/app/src/models/user.rs b/app/src/models/user.rs deleted file mode 100644 index 9bde063..0000000 --- a/app/src/models/user.rs +++ /dev/null @@ -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, -} diff --git a/app/src/models/user/error.rs b/app/src/models/user/error.rs new file mode 100644 index 0000000..668d932 --- /dev/null +++ b/app/src/models/user/error.rs @@ -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), +} diff --git a/server/src/domain/mod.rs b/app/src/models/user/mod.rs similarity index 78% rename from server/src/domain/mod.rs rename to app/src/models/user/mod.rs index 1186991..d4c3e6e 100644 --- a/server/src/domain/mod.rs +++ b/app/src/models/user/mod.rs @@ -1,3 +1,4 @@ +pub mod error; pub mod new_user; pub mod user_code; pub mod username; diff --git a/app/src/models/user/new_user.rs b/app/src/models/user/new_user.rs new file mode 100644 index 0000000..74d5160 --- /dev/null +++ b/app/src/models/user/new_user.rs @@ -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() + }) + } +} diff --git a/app/src/models/user/user_code.rs b/app/src/models/user/user_code.rs new file mode 100644 index 0000000..80c5e52 --- /dev/null +++ b/app/src/models/user/user_code.rs @@ -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 + } +} diff --git a/server/src/domain/username.rs b/app/src/models/user/username.rs similarity index 100% rename from server/src/domain/username.rs rename to app/src/models/user/username.rs diff --git a/app/src/server_fn/auth.rs b/app/src/server_fn/auth.rs new file mode 100644 index 0000000..ebd7b6d --- /dev/null +++ b/app/src/server_fn/auth.rs @@ -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(), + )) + } + } +} diff --git a/app/src/server_fn/mod.rs b/app/src/server_fn/mod.rs new file mode 100644 index 0000000..12bc9de --- /dev/null +++ b/app/src/server_fn/mod.rs @@ -0,0 +1 @@ +mod auth; diff --git a/app/src/startup.rs b/app/src/startup.rs new file mode 100644 index 0000000..f77ec0a --- /dev/null +++ b/app/src/startup.rs @@ -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()) +} diff --git a/justfile b/justfile index 346798b..8305c08 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,5 @@ set dotenv-load -export RUSTC_WRAPPER:="sccache" - # List all available commands default: @just --list diff --git a/server/Cargo.toml b/server/Cargo.toml index b43b1ad..e5b5910 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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 diff --git a/server/src/application.rs b/server/src/application.rs new file mode 100644 index 0000000..1173fea --- /dev/null +++ b/server/src/application.rs @@ -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? + } +} diff --git a/server/src/domain/new_user.rs b/server/src/domain/new_user.rs deleted file mode 100644 index 6092521..0000000 --- a/server/src/domain/new_user.rs +++ /dev/null @@ -1,7 +0,0 @@ -use super::{user_code::UserCode, username::Username}; - -#[derive(Debug, Default)] -pub struct NewUser { - pub username: Username, - pub code: UserCode, -} diff --git a/server/src/domain/user_code.rs b/server/src/domain/user_code.rs deleted file mode 100644 index 28bf1d9..0000000 --- a/server/src/domain/user_code.rs +++ /dev/null @@ -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 - } -} diff --git a/server/src/lib.rs b/server/src/lib.rs deleted file mode 100644 index d70ca6e..0000000 --- a/server/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod config; -pub mod domain; -pub mod routes; -pub mod startup; diff --git a/server/src/main.rs b/server/src/main.rs index 2f14d9c..81415a7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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(()) } diff --git a/server/src/routes/api/mod.rs b/server/src/routes/api/mod.rs new file mode 100644 index 0000000..d198c03 --- /dev/null +++ b/server/src/routes/api/mod.rs @@ -0,0 +1,8 @@ +mod v1; + +use app::startup::AppState; +use axum::Router; + +pub fn routes() -> Router<AppState> { + Router::new().nest("/v1", v1::routes()) +} diff --git a/server/src/routes/api/v1/auth.rs b/server/src/routes/api/v1/auth.rs new file mode 100644 index 0000000..a9686bb --- /dev/null +++ b/server/src/routes/api/v1/auth.rs @@ -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")), + )) + } + } +} diff --git a/server/src/routes/api/v1/mod.rs b/server/src/routes/api/v1/mod.rs new file mode 100644 index 0000000..f9de52f --- /dev/null +++ b/server/src/routes/api/v1/mod.rs @@ -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)) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 6b0c4c3..4d50f15 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -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) -} diff --git a/server/src/routes/user/mod.rs b/server/src/routes/user/mod.rs deleted file mode 100644 index 56d7d42..0000000 --- a/server/src/routes/user/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod register; -pub use register::register; diff --git a/server/src/routes/user/register.rs b/server/src/routes/user/register.rs deleted file mode 100644 index dd14345..0000000 --- a/server/src/routes/user/register.rs +++ /dev/null @@ -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() - }) - } -} diff --git a/server/src/startup.rs b/server/src/startup.rs deleted file mode 100644 index 908c6f1..0000000 --- a/server/src/startup.rs +++ /dev/null @@ -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()) -}