mirror of
https://github.com/kristoferssolo/echoes-of-ascension.git
synced 2025-12-31 05:32:33 +00:00
Finish initial setup
This commit is contained in:
parent
8b08f428a2
commit
4d1b5b5376
49
Cargo.lock
generated
49
Cargo.lock
generated
@ -97,23 +97,44 @@ checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"config 0.15.6",
|
||||||
|
"hex",
|
||||||
"http",
|
"http",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
"leptos_meta",
|
"leptos_meta",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
|
"password-hash",
|
||||||
|
"rand",
|
||||||
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde-aux",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.11",
|
"thiserror 2.0.11",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-bunyan-formatter",
|
"tracing-bunyan-formatter",
|
||||||
"tracing-log 0.2.0",
|
"tracing-log 0.2.0",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"unicode-segmentation",
|
||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.18"
|
version = "0.4.18"
|
||||||
@ -289,6 +310,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@ -1892,6 +1922,17 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@ -2419,21 +2460,13 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"app",
|
"app",
|
||||||
"axum",
|
"axum",
|
||||||
"config 0.15.6",
|
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
"rand",
|
|
||||||
"secrecy",
|
|
||||||
"serde",
|
|
||||||
"serde-aux",
|
|
||||||
"sqlx",
|
|
||||||
"thiserror 2.0.11",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-log 0.2.0",
|
"tracing-log 0.2.0",
|
||||||
"unicode-segmentation",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,9 @@ config = { version = "0.15", features = ["toml"], default-features = false }
|
|||||||
serde-aux = "4"
|
serde-aux = "4"
|
||||||
unicode-segmentation = "1"
|
unicode-segmentation = "1"
|
||||||
rand = "0.8"
|
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.
|
# 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)
|
# that are used together frontend (lib) & server (bin)
|
||||||
[[workspace.metadata.leptos]]
|
[[workspace.metadata.leptos]]
|
||||||
# this name is used for the wasm, js and css file names
|
# 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)
|
# the package in the workspace that contains the server binary (binary crate)
|
||||||
bin-package = "server"
|
bin-package = "server"
|
||||||
|
|||||||
@ -21,8 +21,17 @@ tracing-bunyan-formatter.workspace = true
|
|||||||
tracing-log.workspace = true
|
tracing-log.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
serde.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]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
1
app/src/db/mod.rs
Normal file
1
app/src/db/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod users;
|
||||||
27
app/src/db/users.rs
Normal file
27
app/src/db/users.rs
Normal 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(())
|
||||||
|
}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod server_fn;
|
||||||
|
pub mod startup;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
@ -32,7 +36,7 @@ pub fn App() -> impl IntoView {
|
|||||||
provide_meta_context();
|
provide_meta_context();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Stylesheet id="leptos" href="/pkg/start-axum-workspace.css" />
|
<Stylesheet id="leptos" href="/pkg/echoes-of-ascension.css" />
|
||||||
|
|
||||||
// sets the document title
|
// sets the document title
|
||||||
<Title text="Welcome to Leptos" />
|
<Title text="Welcome to Leptos" />
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
|
pub mod response;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
|
|||||||
35
app/src/models/response.rs
Normal file
35
app/src/models/response.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
25
app/src/models/user/error.rs
Normal file
25
app/src/models/user/error.rs
Normal 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),
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
pub mod error;
|
||||||
pub mod new_user;
|
pub mod new_user;
|
||||||
pub mod user_code;
|
pub mod user_code;
|
||||||
pub mod username;
|
pub mod username;
|
||||||
25
app/src/models/user/new_user.rs
Normal file
25
app/src/models/user/new_user.rs
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/src/models/user/user_code.rs
Normal file
73
app/src/models/user/user_code.rs
Normal 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
28
app/src/server_fn/auth.rs
Normal 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
1
app/src/server_fn/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
mod auth;
|
||||||
34
app/src/startup.rs
Normal file
34
app/src/startup.rs
Normal 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())
|
||||||
|
}
|
||||||
2
justfile
2
justfile
@ -1,7 +1,5 @@
|
|||||||
set dotenv-load
|
set dotenv-load
|
||||||
|
|
||||||
export RUSTC_WRAPPER:="sccache"
|
|
||||||
|
|
||||||
# List all available commands
|
# List all available commands
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|||||||
@ -13,15 +13,7 @@ leptos_axum.workspace = true
|
|||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-log.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
|
uuid.workspace = true
|
||||||
unicode-segmentation.workspace = true
|
|
||||||
rand.workspace = true
|
|
||||||
|
|||||||
37
server/src/application.rs
Normal file
37
server/src/application.rs
Normal 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?
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
use super::{user_code::UserCode, username::Username};
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct NewUser {
|
|
||||||
pub username: Username,
|
|
||||||
pub code: UserCode,
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
pub mod config;
|
|
||||||
pub mod domain;
|
|
||||||
pub mod routes;
|
|
||||||
pub mod startup;
|
|
||||||
@ -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 leptos::prelude::*;
|
||||||
use server::config::get_config;
|
|
||||||
use server::startup::{Application, ApplicationError};
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), ApplicationError> {
|
async fn main() -> Result<(), ApplicationError> {
|
||||||
// Generate the list of routes in your Leptos App
|
// 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);
|
init_subscriber(subscriber);
|
||||||
|
|
||||||
let config = get_config().expect("Failed to read configuation.");
|
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?;
|
application.run_until_stopped().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
8
server/src/routes/api/mod.rs
Normal file
8
server/src/routes/api/mod.rs
Normal 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())
|
||||||
|
}
|
||||||
41
server/src/routes/api/v1/auth.rs
Normal file
41
server/src/routes/api/v1/auth.rs
Normal 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")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/src/routes/api/v1/mod.rs
Normal file
8
server/src/routes/api/v1/mod.rs
Normal 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))
|
||||||
|
}
|
||||||
@ -1,30 +1,39 @@
|
|||||||
|
mod api;
|
||||||
mod health_check;
|
mod health_check;
|
||||||
mod user;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use app::{shell, App};
|
use app::{shell, startup::AppState, App};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::MatchedPath,
|
extract::MatchedPath,
|
||||||
http::{HeaderMap, Request},
|
http::{HeaderMap, Request},
|
||||||
response::Response,
|
response::Response,
|
||||||
routing::{get, post},
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use health_check::health_check;
|
use health_check::health_check;
|
||||||
|
|
||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
|
use std::time::Duration;
|
||||||
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
||||||
use tracing::{info_span, Span};
|
use tracing::{info_span, Span};
|
||||||
use user::register;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::startup::AppState;
|
|
||||||
|
|
||||||
pub fn route(state: AppState) -> Router {
|
pub fn route(state: AppState) -> Router {
|
||||||
|
let leptos_options = state.leptos_options.clone();
|
||||||
|
let routes = generate_route_list(App);
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(leptos_routes(state.clone()))
|
.route("/health_check", get(health_check))
|
||||||
.merge(api_routes(state))
|
// 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(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(|request: &Request<_>| {
|
.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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
mod register;
|
|
||||||
pub use register::register;
|
|
||||||
@ -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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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())
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user